Merge remote-tracking branch 'upstream/master' into python-registry-v2
This commit is contained in:
commit
0459c3bc54
55 changed files with 1480 additions and 360 deletions
6
app.py
6
app.py
|
@ -37,7 +37,7 @@ from util.saas.metricqueue import MetricQueue
|
||||||
from util.config.provider import get_config_provider
|
from util.config.provider import get_config_provider
|
||||||
from util.config.configutil import generate_secret_key
|
from util.config.configutil import generate_secret_key
|
||||||
from util.config.superusermanager import SuperUserManager
|
from util.config.superusermanager import SuperUserManager
|
||||||
from util.secscan.secscanendpoint import SecurityScanEndpoint
|
from util.secscan.api import SecurityScannerAPI
|
||||||
|
|
||||||
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
|
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
|
||||||
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
||||||
|
@ -160,7 +160,9 @@ image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf)
|
||||||
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
||||||
reporter=MetricQueueReporter(metric_queue))
|
reporter=MetricQueueReporter(metric_queue))
|
||||||
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf)
|
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf)
|
||||||
secscan_endpoint = SecurityScanEndpoint(app, config_provider)
|
secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf)
|
||||||
|
|
||||||
|
secscan_api = SecurityScannerAPI(app, config_provider)
|
||||||
|
|
||||||
# Check for a key in config. If none found, generate a new signing key for Docker V2 manifests.
|
# Check for a key in config. If none found, generate a new signing key for Docker V2 manifests.
|
||||||
_v2_key_path = os.path.join(OVERRIDE_CONFIG_DIRECTORY, DOCKER_V2_SIGNINGKEY_FILENAME)
|
_v2_key_path = os.path.join(OVERRIDE_CONFIG_DIRECTORY, DOCKER_V2_SIGNINGKEY_FILENAME)
|
||||||
|
|
2
conf/init/service/security_notification_worker/log/run
Executable file
2
conf/init/service/security_notification_worker/log/run
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec logger -i -t securitynotificationworker
|
8
conf/init/service/security_notification_worker/run
Executable file
8
conf/init/service/security_notification_worker/run
Executable file
|
@ -0,0 +1,8 @@
|
||||||
|
#! /bin/bash
|
||||||
|
|
||||||
|
echo 'Starting security scanner notification worker'
|
||||||
|
|
||||||
|
cd /
|
||||||
|
venv/bin/python -m workers.security_notification_worker 2>&1
|
||||||
|
|
||||||
|
echo 'Security scanner notification worker exited'
|
0
conf/init/service/securityworker/log/run
Normal file → Executable file
0
conf/init/service/securityworker/log/run
Normal file → Executable file
0
conf/init/service/securityworker/run
Normal file → Executable file
0
conf/init/service/securityworker/run
Normal file → Executable file
|
@ -50,7 +50,7 @@ class DefaultConfig(object):
|
||||||
|
|
||||||
CONTACT_INFO = [
|
CONTACT_INFO = [
|
||||||
'mailto:support@quay.io',
|
'mailto:support@quay.io',
|
||||||
'irc://chat.freenode.net:6665/quayio',
|
'irc://chat.freenode.net:6665/quay',
|
||||||
'tel:+1-888-930-3475',
|
'tel:+1-888-930-3475',
|
||||||
'https://twitter.com/quayio',
|
'https://twitter.com/quayio',
|
||||||
]
|
]
|
||||||
|
@ -130,6 +130,7 @@ class DefaultConfig(object):
|
||||||
DIFFS_QUEUE_NAME = 'imagediff'
|
DIFFS_QUEUE_NAME = 'imagediff'
|
||||||
DOCKERFILE_BUILD_QUEUE_NAME = 'dockerfilebuild'
|
DOCKERFILE_BUILD_QUEUE_NAME = 'dockerfilebuild'
|
||||||
REPLICATION_QUEUE_NAME = 'imagestoragereplication'
|
REPLICATION_QUEUE_NAME = 'imagestoragereplication'
|
||||||
|
SECSCAN_NOTIFICATION_QUEUE_NAME = 'secscan_notification'
|
||||||
|
|
||||||
# Super user config. Note: This MUST BE an empty list for the default config.
|
# Super user config. Note: This MUST BE an empty list for the default config.
|
||||||
SUPER_USERS = []
|
SUPER_USERS = []
|
||||||
|
@ -257,7 +258,7 @@ class DefaultConfig(object):
|
||||||
# Security scanner
|
# Security scanner
|
||||||
FEATURE_SECURITY_SCANNER = False
|
FEATURE_SECURITY_SCANNER = False
|
||||||
SECURITY_SCANNER = {
|
SECURITY_SCANNER = {
|
||||||
'ENDPOINT': 'http://192.168.99.100:6060',
|
'ENDPOINT': 'http://192.168.99.101:6060',
|
||||||
'ENGINE_VERSION_TARGET': 1,
|
'ENGINE_VERSION_TARGET': 1,
|
||||||
'API_VERSION': 'v1',
|
'API_VERSION': 'v1',
|
||||||
'API_TIMEOUT_SECONDS': 10,
|
'API_TIMEOUT_SECONDS': 10,
|
||||||
|
|
|
@ -14,7 +14,6 @@ from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from util.migrate import UTF8LongText
|
from util.migrate import UTF8LongText
|
||||||
|
|
||||||
|
|
||||||
def upgrade(tables):
|
def upgrade(tables):
|
||||||
### commands auto generated by Alembic - please adjust! ###
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('repositorynotification', sa.Column('event_config_json', UTF8LongText, nullable=False))
|
op.add_column('repositorynotification', sa.Column('event_config_json', UTF8LongText, nullable=False))
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
from peewee import JOIN_LEFT_OUTER, fn
|
from peewee import JOIN_LEFT_OUTER, fn, SQL
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from data.model import (DataModelException, db_transaction, _basequery, storage,
|
from data.model import (DataModelException, db_transaction, _basequery, storage,
|
||||||
|
@ -13,6 +13,25 @@ from data.database import (Image, Repository, ImageStoragePlacement, Namespace,
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository_image_and_deriving(docker_image_id, storage_uuid):
|
||||||
|
""" Returns all matching images with the given docker image ID and storage uuid, along with any
|
||||||
|
images which have the image ID as parents.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image_found = (Image
|
||||||
|
.select()
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.docker_image_id == docker_image_id,
|
||||||
|
ImageStorage.uuid == storage_uuid)
|
||||||
|
.get())
|
||||||
|
except Image.DoesNotExist:
|
||||||
|
return Image.select().where(Image.id < 0) # Empty query
|
||||||
|
|
||||||
|
ancestors_pattern = '%s%s/%%' % (image_found.ancestors, image_found.id)
|
||||||
|
return Image.select().where((Image.ancestors ** ancestors_pattern) |
|
||||||
|
(Image.id == image_found.id))
|
||||||
|
|
||||||
|
|
||||||
def get_parent_images(namespace_name, repository_name, image_obj):
|
def get_parent_images(namespace_name, repository_name, image_obj):
|
||||||
""" Returns a list of parent Image objects starting with the most recent parent
|
""" Returns a list of parent Image objects starting with the most recent parent
|
||||||
and ending with the base layer.
|
and ending with the base layer.
|
||||||
|
|
|
@ -13,6 +13,20 @@ def _tag_alive(query, now_ts=None):
|
||||||
(RepositoryTag.lifetime_end_ts > now_ts))
|
(RepositoryTag.lifetime_end_ts > now_ts))
|
||||||
|
|
||||||
|
|
||||||
|
def get_matching_tags(docker_image_id, storage_uuid, *args):
|
||||||
|
""" Returns a query pointing to all tags that contain the image with the
|
||||||
|
given docker_image_id and storage_uuid. """
|
||||||
|
image_query = image.get_repository_image_and_deriving(docker_image_id, storage_uuid)
|
||||||
|
|
||||||
|
return (RepositoryTag
|
||||||
|
.select(*args)
|
||||||
|
.distinct()
|
||||||
|
.join(Image)
|
||||||
|
.join(ImageStorage)
|
||||||
|
.where(Image.id << image_query, RepositoryTag.lifetime_end_ts >> None,
|
||||||
|
RepositoryTag.hidden == False))
|
||||||
|
|
||||||
|
|
||||||
def list_repository_tags(namespace_name, repository_name, include_hidden=False,
|
def list_repository_tags(namespace_name, repository_name, include_hidden=False,
|
||||||
include_storage=False):
|
include_storage=False):
|
||||||
to_select = (RepositoryTag, Image)
|
to_select = (RepositoryTag, Image)
|
||||||
|
|
|
@ -22,12 +22,19 @@ def notification_view(note):
|
||||||
except:
|
except:
|
||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
|
event_config = {}
|
||||||
|
try:
|
||||||
|
event_config = json.loads(note.event_config_json)
|
||||||
|
except:
|
||||||
|
event_config = {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'uuid': note.uuid,
|
'uuid': note.uuid,
|
||||||
'event': note.event.name,
|
'event': note.event.name,
|
||||||
'method': note.method.name,
|
'method': note.method.name,
|
||||||
'config': config,
|
'config': config,
|
||||||
'title': note.title,
|
'title': note.title,
|
||||||
|
'event_config': event_config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -160,7 +167,7 @@ class TestRepositoryNotification(RepositoryParamResource):
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
|
|
||||||
event_info = NotificationEvent.get_event(test_note.event.name)
|
event_info = NotificationEvent.get_event(test_note.event.name)
|
||||||
sample_data = event_info.get_sample_data(repository=test_note.repository)
|
sample_data = event_info.get_sample_data(test_note)
|
||||||
notification_data = build_notification_data(test_note, sample_data)
|
notification_data = build_notification_data(test_note, sample_data)
|
||||||
notification_queue.put([test_note.repository.namespace_user.username, repository,
|
notification_queue.put([test_note.repository.namespace_user.username, repository,
|
||||||
test_note.event.name], json.dumps(notification_data))
|
test_note.event.name], json.dumps(notification_data))
|
||||||
|
|
|
@ -5,7 +5,7 @@ import features
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from app import secscan_endpoint
|
from app import secscan_api
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param,
|
from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param,
|
||||||
RepositoryParamResource, resource, nickname, show_if, parse_args,
|
RepositoryParamResource, resource, nickname, show_if, parse_args,
|
||||||
|
@ -15,10 +15,17 @@ from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_pa
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SCAN_STATUS(object):
|
||||||
|
""" Security scan status enum """
|
||||||
|
SCANNED = 'scanned'
|
||||||
|
FAILED = 'failed'
|
||||||
|
QUEUED = 'queued'
|
||||||
|
|
||||||
|
|
||||||
def _call_security_api(relative_url, *args, **kwargs):
|
def _call_security_api(relative_url, *args, **kwargs):
|
||||||
""" Issues an HTTP call to the sec API at the given relative URL. """
|
""" Issues an HTTP call to the sec API at the given relative URL. """
|
||||||
try:
|
try:
|
||||||
response = secscan_endpoint.call_api(relative_url, *args, **kwargs)
|
response = secscan_api.call(relative_url, None, *args, **kwargs)
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
raise DownstreamIssue(payload=dict(message='API call timed out'))
|
raise DownstreamIssue(payload=dict(message='API call timed out'))
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
|
@ -39,37 +46,44 @@ def _call_security_api(relative_url, *args, **kwargs):
|
||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status(repo_image):
|
||||||
|
if repo_image.security_indexed_engine is not None and repo_image.security_indexed_engine >= 0:
|
||||||
|
return SCAN_STATUS.SCANNED if repo_image.security_indexed else SCAN_STATUS.FAILED
|
||||||
|
|
||||||
|
return SCAN_STATUS.QUEUED
|
||||||
|
|
||||||
|
|
||||||
@show_if(features.SECURITY_SCANNER)
|
@show_if(features.SECURITY_SCANNER)
|
||||||
@resource('/v1/repository/<repopath:repository>/tag/<tag>/vulnerabilities')
|
@resource('/v1/repository/<repopath:repository>/image/<imageid>/vulnerabilities')
|
||||||
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||||
@path_param('tag', 'The name of the tag')
|
@path_param('imageid', 'The image ID')
|
||||||
class RepositoryTagVulnerabilities(RepositoryParamResource):
|
class RepositoryImageVulnerabilities(RepositoryParamResource):
|
||||||
""" Operations for managing the vulnerabilities in a repository tag. """
|
""" Operations for managing the vulnerabilities in a repository image. """
|
||||||
|
|
||||||
@require_repo_read
|
@require_repo_read
|
||||||
@nickname('getRepoTagVulnerabilities')
|
@nickname('getRepoImageVulnerabilities')
|
||||||
@parse_args
|
@parse_args
|
||||||
@query_param('minimumPriority', 'Minimum vulnerability priority', type=str,
|
@query_param('minimumPriority', 'Minimum vulnerability priority', type=str,
|
||||||
default='Low')
|
default='Low')
|
||||||
def get(self, args, namespace, repository, tag):
|
def get(self, args, namespace, repository, imageid):
|
||||||
""" Fetches the vulnerabilities (if any) for a repository tag. """
|
""" Fetches the vulnerabilities (if any) for a repository tag. """
|
||||||
try:
|
repo_image = model.image.get_repo_image(namespace, repository, imageid)
|
||||||
tag_image = model.tag.get_tag_image(namespace, repository, tag)
|
if repo_image is None:
|
||||||
except model.DataModelException:
|
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
|
|
||||||
if not tag_image.security_indexed:
|
if not repo_image.security_indexed:
|
||||||
logger.debug('Image %s for tag %s under repository %s/%s not security indexed',
|
logger.debug('Image %s under repository %s/%s not security indexed',
|
||||||
tag_image.docker_image_id, tag, namespace, repository)
|
repo_image.docker_image_id, namespace, repository)
|
||||||
return {
|
return {
|
||||||
'security_indexed': False
|
'status': _get_status(repo_image),
|
||||||
}
|
}
|
||||||
|
|
||||||
data = _call_security_api('layers/%s/vulnerabilities', tag_image.docker_image_id,
|
layer_id = '%s.%s' % (repo_image.docker_image_id, repo_image.storage.uuid)
|
||||||
|
data = _call_security_api('layers/%s/vulnerabilities', layer_id,
|
||||||
minimumPriority=args.minimumPriority)
|
minimumPriority=args.minimumPriority)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'security_indexed': True,
|
'status': _get_status(repo_image),
|
||||||
'data': data,
|
'data': data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,13 +105,14 @@ class RepositoryImagePackages(RepositoryParamResource):
|
||||||
|
|
||||||
if not repo_image.security_indexed:
|
if not repo_image.security_indexed:
|
||||||
return {
|
return {
|
||||||
'security_indexed': False
|
'status': _get_status(repo_image),
|
||||||
}
|
}
|
||||||
|
|
||||||
data = _call_security_api('layers/%s/packages/diff', repo_image.docker_image_id)
|
layer_id = '%s.%s' % (repo_image.docker_image_id, repo_image.storage.uuid)
|
||||||
|
data = _call_security_api('layers/%s/packages', layer_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'security_indexed': True,
|
'status': _get_status(repo_image),
|
||||||
'data': data,
|
'data': data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ from werkzeug.routing import BaseConverter
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from config import frontend_visible_config
|
from config import frontend_visible_config
|
||||||
from external_libraries import get_external_javascript, get_external_css
|
from external_libraries import get_external_javascript, get_external_css
|
||||||
|
from util.secscan.api import PRIORITY_LEVELS
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
|
@ -183,6 +184,7 @@ def render_page_template(name, **kwargs):
|
||||||
config_set=json.dumps(frontend_visible_config(app.config)),
|
config_set=json.dumps(frontend_visible_config(app.config)),
|
||||||
oauth_set=json.dumps(get_oauth_config()),
|
oauth_set=json.dumps(get_oauth_config()),
|
||||||
scope_set=json.dumps(scopes.app_scopes(app.config)),
|
scope_set=json.dumps(scopes.app_scopes(app.config)),
|
||||||
|
vuln_priority_set=json.dumps(PRIORITY_LEVELS),
|
||||||
mixpanel_key=app.config.get('MIXPANEL_KEY', ''),
|
mixpanel_key=app.config.get('MIXPANEL_KEY', ''),
|
||||||
google_analytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''),
|
google_analytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''),
|
||||||
sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''),
|
sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''),
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from notificationhelper import build_event_data
|
from notificationhelper import build_event_data
|
||||||
from util.jinjautil import get_template_env
|
from util.jinjautil import get_template_env
|
||||||
|
from util.secscan.api import PRIORITY_LEVELS, get_priority_for_index
|
||||||
|
|
||||||
template_env = get_template_env("events")
|
template_env = get_template_env("events")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -37,13 +39,18 @@ class NotificationEvent(object):
|
||||||
'notification_data': notification_data
|
'notification_data': notification_data
|
||||||
})
|
})
|
||||||
|
|
||||||
def get_sample_data(self, repository=None):
|
def get_sample_data(self, notification):
|
||||||
"""
|
"""
|
||||||
Returns sample data for testing the raising of this notification, with an optional
|
Returns sample data for testing the raising of this notification, with an example notification.
|
||||||
repository.
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def should_perform(self, event_data, notification_data):
|
||||||
|
"""
|
||||||
|
Whether a notification for this event should be performed. By default returns True.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def event_name(cls):
|
def event_name(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -71,8 +78,8 @@ class RepoPushEvent(NotificationEvent):
|
||||||
def get_summary(self, event_data, notification_data):
|
def get_summary(self, event_data, notification_data):
|
||||||
return 'Repository %s updated' % (event_data['repository'])
|
return 'Repository %s updated' % (event_data['repository'])
|
||||||
|
|
||||||
def get_sample_data(self, repository):
|
def get_sample_data(self, notification):
|
||||||
return build_event_data(repository, {
|
return build_event_data(notification.repository, {
|
||||||
'updated_tags': {'latest': 'someimageid', 'foo': 'anotherimage'},
|
'updated_tags': {'latest': 'someimageid', 'foo': 'anotherimage'},
|
||||||
'pruned_image_count': 3
|
'pruned_image_count': 3
|
||||||
})
|
})
|
||||||
|
@ -99,18 +106,27 @@ class VulnerabilityFoundEvent(NotificationEvent):
|
||||||
|
|
||||||
return 'info'
|
return 'info'
|
||||||
|
|
||||||
def get_sample_data(self, repository):
|
def get_sample_data(self, notification):
|
||||||
return build_event_data(repository, {
|
event_config = json.loads(notification.event_config_json)
|
||||||
|
|
||||||
|
return build_event_data(notification.repository, {
|
||||||
'tags': ['latest', 'prod'],
|
'tags': ['latest', 'prod'],
|
||||||
'image': 'some-image-id',
|
'image': 'some-image-id',
|
||||||
'vulnerability': {
|
'vulnerability': {
|
||||||
'id': 'CVE-FAKE-CVE',
|
'id': 'CVE-FAKE-CVE',
|
||||||
'description': 'A futurist vulnerability',
|
'description': 'A futurist vulnerability',
|
||||||
'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE',
|
'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE',
|
||||||
'priority': 'Critical',
|
'priority': get_priority_for_index(event_config['level'])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def should_perform(self, event_data, notification_data):
|
||||||
|
event_config = json.loads(notification_data.event_config_json)
|
||||||
|
expected_level_index = event_config['level']
|
||||||
|
priority = PRIORITY_LEVELS[event_data['vulnerability']['priority']]
|
||||||
|
actual_level_index = priority['index']
|
||||||
|
return expected_level_index <= actual_level_index
|
||||||
|
|
||||||
def get_summary(self, event_data, notification_data):
|
def get_summary(self, event_data, notification_data):
|
||||||
msg = '%s vulnerability detected in repository %s in tags %s'
|
msg = '%s vulnerability detected in repository %s in tags %s'
|
||||||
return msg % (event_data['vulnerability']['priority'],
|
return msg % (event_data['vulnerability']['priority'],
|
||||||
|
@ -126,10 +142,10 @@ class BuildQueueEvent(NotificationEvent):
|
||||||
def get_level(self, event_data, notification_data):
|
def get_level(self, event_data, notification_data):
|
||||||
return 'info'
|
return 'info'
|
||||||
|
|
||||||
def get_sample_data(self, repository):
|
def get_sample_data(self, notification):
|
||||||
build_uuid = 'fake-build-id'
|
build_uuid = 'fake-build-id'
|
||||||
|
|
||||||
return build_event_data(repository, {
|
return build_event_data(notification.repository, {
|
||||||
'is_manual': False,
|
'is_manual': False,
|
||||||
'build_id': build_uuid,
|
'build_id': build_uuid,
|
||||||
'build_name': 'some-fake-build',
|
'build_name': 'some-fake-build',
|
||||||
|
@ -165,10 +181,10 @@ class BuildStartEvent(NotificationEvent):
|
||||||
def get_level(self, event_data, notification_data):
|
def get_level(self, event_data, notification_data):
|
||||||
return 'info'
|
return 'info'
|
||||||
|
|
||||||
def get_sample_data(self, repository):
|
def get_sample_data(self, notification):
|
||||||
build_uuid = 'fake-build-id'
|
build_uuid = 'fake-build-id'
|
||||||
|
|
||||||
return build_event_data(repository, {
|
return build_event_data(notification.repository, {
|
||||||
'build_id': build_uuid,
|
'build_id': build_uuid,
|
||||||
'build_name': 'some-fake-build',
|
'build_name': 'some-fake-build',
|
||||||
'docker_tags': ['latest', 'foo', 'bar'],
|
'docker_tags': ['latest', 'foo', 'bar'],
|
||||||
|
@ -193,10 +209,10 @@ class BuildSuccessEvent(NotificationEvent):
|
||||||
def get_level(self, event_data, notification_data):
|
def get_level(self, event_data, notification_data):
|
||||||
return 'success'
|
return 'success'
|
||||||
|
|
||||||
def get_sample_data(self, repository):
|
def get_sample_data(self, notification):
|
||||||
build_uuid = 'fake-build-id'
|
build_uuid = 'fake-build-id'
|
||||||
|
|
||||||
return build_event_data(repository, {
|
return build_event_data(notification.repository, {
|
||||||
'build_id': build_uuid,
|
'build_id': build_uuid,
|
||||||
'build_name': 'some-fake-build',
|
'build_name': 'some-fake-build',
|
||||||
'docker_tags': ['latest', 'foo', 'bar'],
|
'docker_tags': ['latest', 'foo', 'bar'],
|
||||||
|
@ -222,10 +238,10 @@ class BuildFailureEvent(NotificationEvent):
|
||||||
def get_level(self, event_data, notification_data):
|
def get_level(self, event_data, notification_data):
|
||||||
return 'error'
|
return 'error'
|
||||||
|
|
||||||
def get_sample_data(self, repository):
|
def get_sample_data(self, notification):
|
||||||
build_uuid = 'fake-build-id'
|
build_uuid = 'fake-build-id'
|
||||||
|
|
||||||
return build_event_data(repository, {
|
return build_event_data(notification.repository, {
|
||||||
'build_id': build_uuid,
|
'build_id': build_uuid,
|
||||||
'build_name': 'some-fake-build',
|
'build_name': 'some-fake-build',
|
||||||
'docker_tags': ['latest', 'foo', 'bar'],
|
'docker_tags': ['latest', 'foo', 'bar'],
|
||||||
|
|
25
endpoints/secscan.py
Normal file
25
endpoints/secscan.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
import features
|
||||||
|
|
||||||
|
from app import secscan_notification_queue
|
||||||
|
from flask import request, make_response, Blueprint
|
||||||
|
from endpoints.common import route_show_if
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
secscan = Blueprint('secscan', __name__)
|
||||||
|
|
||||||
|
@route_show_if(features.SECURITY_SCANNER)
|
||||||
|
@secscan.route('/notification', methods=['POST'])
|
||||||
|
def secscan_notification():
|
||||||
|
data = request.get_json()
|
||||||
|
logger.debug('Got notification from Clair: %s', data)
|
||||||
|
|
||||||
|
content = data['Content']
|
||||||
|
layer_ids = content.get('NewIntroducingLayersIDs', content.get('IntroducingLayersIDs', []))
|
||||||
|
if not layer_ids:
|
||||||
|
return make_response('Okay')
|
||||||
|
|
||||||
|
secscan_notification_queue.put(['notification', data['Name']], json.dumps(data))
|
||||||
|
return make_response('Okay')
|
|
@ -7,7 +7,7 @@ from functools import wraps
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from app import storage as store, image_diff_queue, image_replication_queue, app
|
from app import storage as store, image_replication_queue, app
|
||||||
from auth.auth import process_auth, extract_namespace_repo_from_session
|
from auth.auth import process_auth, extract_namespace_repo_from_session
|
||||||
from auth.auth_context import get_authenticated_user, get_grant_user_context
|
from auth.auth_context import get_authenticated_user, get_grant_user_context
|
||||||
from digest import checksums
|
from digest import checksums
|
||||||
|
@ -41,20 +41,9 @@ def _finish_image(namespace, repository, repo_image):
|
||||||
# Checksum is ok, we remove the marker
|
# Checksum is ok, we remove the marker
|
||||||
set_uploading_flag(repo_image, False)
|
set_uploading_flag(repo_image, False)
|
||||||
|
|
||||||
image_id = repo_image.docker_image_id
|
|
||||||
|
|
||||||
# The layer is ready for download, send a job to the work queue to
|
|
||||||
# process it.
|
|
||||||
logger.debug('Adding layer to diff queue')
|
|
||||||
repo = model.repository.get_repository(namespace, repository)
|
|
||||||
image_diff_queue.put([repo.namespace_user.username, repository, image_id], json.dumps({
|
|
||||||
'namespace_user_id': repo.namespace_user.id,
|
|
||||||
'repository': repository,
|
|
||||||
'image_id': image_id,
|
|
||||||
}))
|
|
||||||
|
|
||||||
# Send a job to the work queue to replicate the image layer.
|
# Send a job to the work queue to replicate the image layer.
|
||||||
if features.STORAGE_REPLICATION:
|
if features.STORAGE_REPLICATION:
|
||||||
|
repo = model.repository.get_repository(namespace, repository)
|
||||||
image_replication_queue.put([repo_image.storage.uuid], json.dumps({
|
image_replication_queue.put([repo_image.storage.uuid], json.dumps({
|
||||||
'namespace_user_id': repo.namespace_user.id,
|
'namespace_user_id': repo.namespace_user.id,
|
||||||
'storage_id': repo_image.storage.uuid,
|
'storage_id': repo_image.storage.uuid,
|
||||||
|
|
2
pylintrc
2
pylintrc
|
@ -9,7 +9,7 @@
|
||||||
# --enable=similarities". If you want to run only the classes checker, but have
|
# --enable=similarities". If you want to run only the classes checker, but have
|
||||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||||
# --disable=W"
|
# --disable=W"
|
||||||
disable=missing-docstring
|
disable=missing-docstring,invalid-name,too-many-locals
|
||||||
|
|
||||||
[TYPECHECK]
|
[TYPECHECK]
|
||||||
|
|
||||||
|
|
|
@ -3,10 +3,56 @@
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 300;
|
||||||
|
padding-left: 70px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls .sec-logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 17px;
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls .sec-logo .lock {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls b {
|
||||||
|
color: #333;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls .configure-alerts {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls .configure-alerts .fa {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-info-element .right-sec-controls .repository-events-summary {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.repo-panel-info-element .right-controls .copy-box {
|
.repo-panel-info-element .right-controls .copy-box {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
display: inline-block;
|
margin-top: 10px;
|
||||||
margin-left: 10px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-panel-info-element .stat-col {
|
.repo-panel-info-element .stat-col {
|
||||||
|
|
|
@ -85,6 +85,37 @@
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .security-scan-col span {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .security-scan-col i.fa {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .security-scan-col .scanning,
|
||||||
|
.repo-panel-tags-element .security-scan-col .failed-scan,
|
||||||
|
.repo-panel-tags-element .security-scan-col .vuln-load-error {
|
||||||
|
color: #9B9B9B;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .security-scan-col .no-vulns a {
|
||||||
|
color: #2FC98E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .security-scan-col .vuln-link,
|
||||||
|
.repo-panel-tags-element .security-scan-col .vuln-link span {
|
||||||
|
text-decoration: none !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .security-scan-col .has-vulns.Critical .highest-vuln,
|
||||||
|
.repo-panel-tags-element .security-scan-col .has-vulns.Defcon1 .highest-vuln {
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .other-vulns {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.repo-panel-tags-element .tag-span {
|
.repo-panel-tags-element .tag-span {
|
||||||
|
|
|
@ -16,3 +16,34 @@
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-box.floating {
|
||||||
|
float: right;
|
||||||
|
min-width: 300px;
|
||||||
|
margin-top: 15px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-box.floating .filter-message {
|
||||||
|
position: absolute;
|
||||||
|
left: -200px;
|
||||||
|
top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.filter-box.floating {
|
||||||
|
float: none;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-box.floating .form-control {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-box.floating .filter-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
21
static/css/directives/ui/repository-events-summary.css
Normal file
21
static/css/directives/ui/repository-events-summary.css
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.repository-events-summary-element .summary-list {
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repository-events-summary-element .summary-list li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repository-events-summary-element .summary-list li i.fa {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repository-events-summary-element .notification-event-fields {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
|
@ -1,3 +1,13 @@
|
||||||
.repository-events-table-element .notification-row i.fa {
|
.repository-events-table-element .notification-row i.fa {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repository-events-table-element .notification-event-fields {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0px;
|
||||||
|
margin-left: 28px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
19
static/css/directives/ui/vulnerability-priority-view.css
Normal file
19
static/css/directives/ui/vulnerability-priority-view.css
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
.vulnerability-priority-view-element i.fa {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-priority-view-element.Unknown,
|
||||||
|
.vulnerability-priority-view-element.Low,
|
||||||
|
.vulnerability-priority-view-element.Negligible {
|
||||||
|
color: #9B9B9B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-priority-view-element.Medium {
|
||||||
|
color: #FCA657;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-priority-view-element.High,
|
||||||
|
.vulnerability-priority-view-element.Critical,
|
||||||
|
.vulnerability-priority-view-element.Defcon1 {
|
||||||
|
color: #D64456;
|
||||||
|
}
|
|
@ -21,5 +21,50 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-view .co-tab-content h3 {
|
.image-view .co-tab-content h3 {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .fa-bug {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .co-filter-box {
|
||||||
|
float: right;
|
||||||
|
min-width: 300px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .co-filter-box .current-filtered {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .co-filter-box input {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .level-col h4 {
|
||||||
|
margin-top: 0px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-view .levels {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .levels li {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .levels li .description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-view .level-col {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
|
@ -24,14 +24,16 @@
|
||||||
<li><a ng-href="/tour/" target="{{ appLinkTarget() }}" quay-section="tour">Tour</a></li>
|
<li><a ng-href="/tour/" target="{{ appLinkTarget() }}" quay-section="tour">Tour</a></li>
|
||||||
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}" quay-section="tutorial">Tutorial</a></li>
|
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}" quay-section="tutorial">Tutorial</a></li>
|
||||||
<li quay-require="['BILLING']"><a ng-href="/plans/" target="{{ appLinkTarget() }}" quay-section="plans">Pricing</a></li>
|
<li quay-require="['BILLING']"><a ng-href="/plans/" target="{{ appLinkTarget() }}" quay-section="plans">Pricing</a></li>
|
||||||
<li><a href="http://docs.quay.io/" target="_blank">Docs</a></li>
|
<li><a href="https://docs.quay.io/" target="_blank">Docs</a></li>
|
||||||
|
<li><a href="https://blog.quay.io/" target="_blank">Blog</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Signed in -->
|
<!-- Signed in -->
|
||||||
<ul class="nav navbar-nav navbar-links" ng-if="!user.anonymous">
|
<ul class="nav navbar-nav navbar-links" ng-if="!user.anonymous">
|
||||||
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}" quay-section="repository">Repositories</a></li>
|
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}" quay-section="repository">Repositories</a></li>
|
||||||
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}" quay-section="tutorial">Tutorial</a></li>
|
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}" quay-section="tutorial">Tutorial</a></li>
|
||||||
<li><a href="http://docs.quay.io/" target="_blank">Docs</a></li>
|
<li><a href="https://docs.quay.io/" target="_blank">Docs</a></li>
|
||||||
|
<li><a href="https://blog.quay.io/" target="_blank">Blog</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Phone -->
|
<!-- Phone -->
|
||||||
|
|
|
@ -65,7 +65,25 @@
|
||||||
|
|
||||||
<!-- Pull Controls -->
|
<!-- Pull Controls -->
|
||||||
<div class="right-controls hidden-sm hidden-xs">
|
<div class="right-controls hidden-sm hidden-xs">
|
||||||
Pull Full Repository: <div class="copy-box" hovering-message="true" value="pullCommand"></div>
|
<div class="right-pull-controls">
|
||||||
|
<div>Pull this container with the following Docker command:</div>
|
||||||
|
<div class="copy-box" hovering-message="true" value="pullCommand"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-sec-controls" quay-show="repository.can_admin && Features.SECURITY_SCANNER">
|
||||||
|
<span class="sec-logo">
|
||||||
|
<img class="lock" src="/static/img/lock.svg">
|
||||||
|
<img class="scan" src="/static/img/scan.svg">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<b>Automated Security Scanning (Preview)</b>
|
||||||
|
<div>Continually scanning this repository for 17K+ known vulnerabilities. <a href="https://blog.quay.io/security-scanning-beta" target="_blank">Read more about this feature</a>.</div>
|
||||||
|
<div class="configure-alerts" ng-if="!hasEvents">
|
||||||
|
<a href="/repository/{{ repository.namespace }}/{{ repository.name }}?tab=settings&add_event=vulnerability_found"><i class="fa fa-bell-o"></i>Configure Vulnerability Alerts</a>
|
||||||
|
</div>
|
||||||
|
<div class="repository-events-summary" is-enabled="repository.can_admin" repository="repository"
|
||||||
|
event-filter="vulnerability_found" has-events="hasEvents"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 style="font-size:20px;">Description</h4>
|
<h4 style="font-size:20px;">Description</h4>
|
||||||
|
|
|
@ -81,6 +81,12 @@
|
||||||
style="min-width: 120px;">
|
style="min-width: 120px;">
|
||||||
<a href="javascript:void(0)" ng-click="orderBy('last_modified_datetime')">Last Modified</a>
|
<a href="javascript:void(0)" ng-click="orderBy('last_modified_datetime')">Last Modified</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="hidden-xs"
|
||||||
|
ng-class="tablePredicateClass('security_scanned', options.predicate, options.reverse)"
|
||||||
|
style="min-width: 120px;"
|
||||||
|
quay-require="['SECURITY_SCANNER']">
|
||||||
|
Security Scan
|
||||||
|
</td>
|
||||||
<td class="hidden-xs"
|
<td class="hidden-xs"
|
||||||
ng-class="tablePredicateClass('size', options.predicate, options.reverse)"
|
ng-class="tablePredicateClass('size', options.predicate, options.reverse)"
|
||||||
style="min-width: 62px;">
|
style="min-width: 62px;">
|
||||||
|
@ -107,6 +113,61 @@
|
||||||
<span am-time-ago="tag.last_modified" bo-if="tag.last_modified"></span>
|
<span am-time-ago="tag.last_modified" bo-if="tag.last_modified"></span>
|
||||||
<span bo-if="!tag.last_modified">Unknown</span>
|
<span bo-if="!tag.last_modified">Unknown</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td quay-require="['SECURITY_SCANNER']" class="security-scan-col">
|
||||||
|
<span class="cor-loader-inline" ng-if="getTagVulnerabilities(tag).loading"></span>
|
||||||
|
<span class="vuln-load-error" ng-if="getTagVulnerabilities(tag).hasError">
|
||||||
|
Could not load security information
|
||||||
|
</span>
|
||||||
|
<span ng-if="!getTagVulnerabilities(tag).loading">
|
||||||
|
<!-- Queued -->
|
||||||
|
<span class="scanning" ng-if="getTagVulnerabilities(tag).status == 'queued'"
|
||||||
|
data-title="The image for this tag is queued to be scanned for vulnerabilities"
|
||||||
|
bs-tooltip>Queued for scan</span>
|
||||||
|
|
||||||
|
<!-- Scan Failed -->
|
||||||
|
<span class="failed-scan" ng-if="getTagVulnerabilities(tag).status == 'failed'"
|
||||||
|
data-title="The image for this tag could not be scanned for vulnerabilities"
|
||||||
|
bs-tooltip>
|
||||||
|
<i class="fa fa-times-circle"></i>
|
||||||
|
Failed to scan
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- No Vulns -->
|
||||||
|
<span class="no-vulns"
|
||||||
|
ng-if="getTagVulnerabilities(tag).status == 'scanned' && !getTagVulnerabilities(tag).hasVulnerabilities"
|
||||||
|
data-title="The image for this tag has no vulnerabilities as found in our database"
|
||||||
|
bs-tooltip
|
||||||
|
bindonce>
|
||||||
|
<a bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}?tab=security">
|
||||||
|
<i class="fa fa-check-circle"></i>
|
||||||
|
Passed
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Vulns -->
|
||||||
|
<span ng-if="getTagVulnerabilities(tag).status == 'scanned' && getTagVulnerabilities(tag).hasVulnerabilities"
|
||||||
|
ng-class="getTagVulnerabilities(tag).highestVulnerability.Priority"
|
||||||
|
class="has-vulns" bindonce>
|
||||||
|
<a class="vuln-link" bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}?tab=security"
|
||||||
|
data-title="The image for this tag has {{ getTagVulnerabilities(tag).highestVulnerability.Count }} {{ getTagVulnerabilities(tag).highestVulnerability.Priority }} level vulnerabilities"
|
||||||
|
bs-tooltip>
|
||||||
|
<span class="highest-vuln">
|
||||||
|
<span class="vulnerability-priority-view" priority="getTagVulnerabilities(tag).highestVulnerability.Priority">
|
||||||
|
{{ getTagVulnerabilities(tag).highestVulnerability.Count }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span ng-if="getTagVulnerabilities(tag).vulnerabilities.length - getTagVulnerabilities(tag).highestVulnerability.Count > 0"
|
||||||
|
class="other-vulns">
|
||||||
|
+ {{ getTagVulnerabilities(tag).vulnerabilities.length - getTagVulnerabilities(tag).highestVulnerability.Count }} others
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}?tab=security" style="display: inline-block; margin-left: 6px;">
|
||||||
|
More Info
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="hidden-xs" bo-text="tag.size | bytes"></td>
|
<td class="hidden-xs" bo-text="tag.size | bytes"></td>
|
||||||
<td class="hidden-xs image-id-col">
|
<td class="hidden-xs image-id-col">
|
||||||
<span class="image-link" repository="repository" image-id="tag.image_id"></span>
|
<span class="image-link" repository="repository" image-id="tag.image_id"></span>
|
||||||
|
|
22
static/directives/repository-events-summary.html
Normal file
22
static/directives/repository-events-summary.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<div class="repository-events-summary-element">
|
||||||
|
<div class="resource-view" resource="notificationsResource"
|
||||||
|
error-message="'Could not load repository events'">
|
||||||
|
<ul class="summary-list">
|
||||||
|
<li ng-repeat="notification in notifications">
|
||||||
|
<i class="fa fa-lg" ng-class="getMethodInfo(notification).icon"></i>
|
||||||
|
{{ getMethodInfo(notification).title }} for
|
||||||
|
|
||||||
|
<ul class="notification-event-fields" ng-if="getEventInfo(notification).fields.length">
|
||||||
|
<li ng-repeat="field in getEventInfo(notification).fields">
|
||||||
|
{{ field.title }} of
|
||||||
|
<span ng-switch on="field.type">
|
||||||
|
<span ng-switch-when="enum">
|
||||||
|
{{ findEnumValue(field.values, notification.event_config[field.name]).title }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -44,6 +44,17 @@
|
||||||
<i class="fa fa-lg" ng-class="getEventInfo(notification).icon"></i>
|
<i class="fa fa-lg" ng-class="getEventInfo(notification).icon"></i>
|
||||||
{{ getEventInfo(notification).title }}
|
{{ getEventInfo(notification).title }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<ul class="notification-event-fields" ng-if="getEventInfo(notification).fields.length">
|
||||||
|
<li ng-repeat="field in getEventInfo(notification).fields">
|
||||||
|
{{ field.title }}:
|
||||||
|
<span ng-switch on="field.type">
|
||||||
|
<span ng-switch-when="enum">
|
||||||
|
{{ findEnumValue(field.values, notification.event_config[field.name]).title }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
|
@ -89,5 +100,6 @@
|
||||||
<div class="create-external-notification-dialog"
|
<div class="create-external-notification-dialog"
|
||||||
repository="repository"
|
repository="repository"
|
||||||
counter="showNewNotificationCounter"
|
counter="showNewNotificationCounter"
|
||||||
|
default-data="newNotificationData"
|
||||||
notification-created="handleNotificationCreated(notification)"></div>
|
notification-created="handleNotificationCreated(notification)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
5
static/directives/vulnerability-priority-view.html
Normal file
5
static/directives/vulnerability-priority-view.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<span class="vulnerability-priority-view-element" ng-class="priority">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
|
<span ng-transclude/>
|
||||||
|
{{ priority }}
|
||||||
|
</span>
|
17
static/img/lock.svg
Normal file
17
static/img/lock.svg
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="13px" height="19px" viewBox="0 0 13 19" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
|
||||||
|
<!-- Generator: Sketch 3.4.1 (15681) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>Artboard 1</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs></defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
|
||||||
|
<g id="Artboard-1" sketch:type="MSArtboardGroup" stroke-width="2" stroke="#1D3447">
|
||||||
|
<g id="Oval-121-+-Rectangle-443" sketch:type="MSLayerGroup" transform="translate(0.000000, 1.000000)">
|
||||||
|
<g sketch:type="MSShapeGroup">
|
||||||
|
<path d="M9.75,3.41251359 C9.75,1.5540428 8.29492544,0.0474545455 6.5,0.0474545455 C4.70507456,0.0474545455 3.25,1.5540428 3.25,3.41251359 L3.25,7.41109091 L9.75,7.41109091 L9.75,3.41251359 Z" id="Oval-121"></path>
|
||||||
|
<rect id="Rectangle-443" fill="#2FC98E" x="0" y="5.77472727" width="13" height="11.4545455" rx="2"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
14
static/img/scan.svg
Normal file
14
static/img/scan.svg
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="33.223px" height="31px" viewBox="0 0 33.223 31" enable-background="new 0 0 33.223 31" xml:space="preserve">
|
||||||
|
<polygon fill-rule="evenodd" clip-rule="evenodd" fill="#1E3547" points="31.311,16.034 33.223,9.604 27.315,10.646 "/>
|
||||||
|
<g>
|
||||||
|
<path fill="#1E3547" d="M3.552,11.75c1.638-5.762,6.937-10,13.217-10s11.579,4.238,13.217,10h1.805C30.107,5.013,24.021,0,16.769,0
|
||||||
|
S3.43,5.013,1.747,11.75H3.552z"/>
|
||||||
|
<path fill="#1E3547" d="M30.06,18.96c-1.54,5.909-6.907,10.29-13.292,10.29S5.018,24.87,3.477,18.96H1.672
|
||||||
|
C3.25,25.845,9.413,31,16.769,31s13.519-5.155,15.097-12.04H30.06z"/>
|
||||||
|
</g>
|
||||||
|
<polygon fill-rule="evenodd" clip-rule="evenodd" fill="#1E3547" points="1.913,14.434 0,20.864 5.909,19.822 "/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1 KiB |
|
@ -10,7 +10,8 @@ angular.module('quay').directive('repoPanelInfo', function () {
|
||||||
restrict: 'C',
|
restrict: 'C',
|
||||||
scope: {
|
scope: {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'builds': '=builds'
|
'builds': '=builds',
|
||||||
|
'isEnabled': '=isEnabled'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, ApiService, Config) {
|
controller: function($scope, $element, ApiService, Config) {
|
||||||
$scope.$watch('repository', function(repository) {
|
$scope.$watch('repository', function(repository) {
|
||||||
|
|
|
@ -18,7 +18,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
|
|
||||||
'getImages': '&getImages'
|
'getImages': '&getImages'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, $filter, $location, ApiService, UIService) {
|
controller: function($scope, $element, $filter, $location, ApiService, UIService, VulnerabilityService) {
|
||||||
var orderBy = $filter('orderBy');
|
var orderBy = $filter('orderBy');
|
||||||
|
|
||||||
$scope.checkedTags = UIService.createCheckStateController([], 'name');
|
$scope.checkedTags = UIService.createCheckStateController([], 'name');
|
||||||
|
@ -34,7 +34,8 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
$scope.tagHistory = {};
|
$scope.tagHistory = {};
|
||||||
$scope.tagActionHandler = null;
|
$scope.tagActionHandler = null;
|
||||||
$scope.showingHistory = false;
|
$scope.showingHistory = false;
|
||||||
$scope.tagsPerPage = 50;
|
$scope.tagsPerPage = 25;
|
||||||
|
$scope.imageVulnerabilities = {};
|
||||||
|
|
||||||
var setTagState = function() {
|
var setTagState = function() {
|
||||||
if (!$scope.repository || !$scope.selectedTags) { return; }
|
if (!$scope.repository || !$scope.selectedTags) { return; }
|
||||||
|
@ -149,6 +150,68 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
setTagState();
|
setTagState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$scope.loadImageVulnerabilities = function(image_id, imageData) {
|
||||||
|
var params = {
|
||||||
|
'imageid': image_id,
|
||||||
|
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.getRepoImageVulnerabilities(null, params).then(function(resp) {
|
||||||
|
imageData.loading = false;
|
||||||
|
imageData.status = resp['status'];
|
||||||
|
|
||||||
|
if (imageData.status == 'scanned') {
|
||||||
|
var vulnerabilities = resp.data.Vulnerabilities;
|
||||||
|
|
||||||
|
imageData.hasVulnerabilities = !!vulnerabilities.length;
|
||||||
|
imageData.vulnerabilities = vulnerabilities;
|
||||||
|
|
||||||
|
var highest = {
|
||||||
|
'Priority': 'Unknown',
|
||||||
|
'Count': 0,
|
||||||
|
'index': 100000
|
||||||
|
};
|
||||||
|
|
||||||
|
resp.data.Vulnerabilities.forEach(function(v) {
|
||||||
|
if (VulnerabilityService.LEVELS[v.Priority].index < highest.index) {
|
||||||
|
highest = {
|
||||||
|
'Priority': v.Priority,
|
||||||
|
'Count': 1,
|
||||||
|
'index': VulnerabilityService.LEVELS[v.Priority].index
|
||||||
|
}
|
||||||
|
} else if (VulnerabilityService.LEVELS[v.Priority].index == highest.index) {
|
||||||
|
highest['Count']++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
imageData.highestVulnerability = highest;
|
||||||
|
}
|
||||||
|
}, function() {
|
||||||
|
imageData.loading = false;
|
||||||
|
imageData.hasError = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getTagVulnerabilities = function(tag) {
|
||||||
|
return $scope.getImageVulnerabilities(tag.image_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getImageVulnerabilities = function(image_id) {
|
||||||
|
if (!$scope.repository) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$scope.imageVulnerabilities[image_id]) {
|
||||||
|
$scope.imageVulnerabilities[image_id] = {
|
||||||
|
'loading': true
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.loadImageVulnerabilities(image_id, $scope.imageVulnerabilities[image_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scope.imageVulnerabilities[image_id];
|
||||||
|
};
|
||||||
|
|
||||||
$scope.clearSelectedTags = function() {
|
$scope.clearSelectedTags = function() {
|
||||||
$scope.checkedTags.setChecked([]);
|
$scope.checkedTags.setChecked([]);
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,7 +11,8 @@ angular.module('quay').directive('createExternalNotificationDialog', function ()
|
||||||
scope: {
|
scope: {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'counter': '=counter',
|
'counter': '=counter',
|
||||||
'notificationCreated': '¬ificationCreated'
|
'notificationCreated': '¬ificationCreated',
|
||||||
|
'defaultData': '=defaultData'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) {
|
controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) {
|
||||||
$scope.currentEvent = null;
|
$scope.currentEvent = null;
|
||||||
|
@ -98,6 +99,13 @@ angular.module('quay').directive('createExternalNotificationDialog', function ()
|
||||||
ApiService.createRepoNotification(data, params).then(function(resp) {
|
ApiService.createRepoNotification(data, params).then(function(resp) {
|
||||||
$scope.status = '';
|
$scope.status = '';
|
||||||
$scope.notificationCreated({'notification': resp});
|
$scope.notificationCreated({'notification': resp});
|
||||||
|
|
||||||
|
// Used by repository-events-summary.
|
||||||
|
if (!$scope.repository._notificationCounter) {
|
||||||
|
$scope.repository._notificationCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.repository._notificationCounter++;
|
||||||
$('#createNotificationModal').modal('hide');
|
$('#createNotificationModal').modal('hide');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -154,6 +162,13 @@ angular.module('quay').directive('createExternalNotificationDialog', function ()
|
||||||
$scope.currentEvent = null;
|
$scope.currentEvent = null;
|
||||||
$scope.currentMethod = null;
|
$scope.currentMethod = null;
|
||||||
$scope.unauthorizedEmail = false;
|
$scope.unauthorizedEmail = false;
|
||||||
|
|
||||||
|
$timeout(function() {
|
||||||
|
if ($scope.defaultData && $scope.defaultData['currentEvent']) {
|
||||||
|
$scope.setEvent($scope.defaultData['currentEvent']);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
$('#createNotificationModal').modal({});
|
$('#createNotificationModal').modal({});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
77
static/js/directives/ui/repository-events-summary.js
Normal file
77
static/js/directives/ui/repository-events-summary.js
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* An element which displays a summary of events on a repository of a particular type.
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('repositoryEventsSummary', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/repository-events-summary.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: false,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'repository': '=repository',
|
||||||
|
'isEnabled': '=isEnabled',
|
||||||
|
'eventFilter': '@eventFilter',
|
||||||
|
'hasEvents': '=hasEvents'
|
||||||
|
},
|
||||||
|
controller: function($scope, ApiService, ExternalNotificationData) {
|
||||||
|
var loadNotifications = function() {
|
||||||
|
if (!$scope.repository || !$scope.isEnabled || !$scope.eventFilter || $scope.notificationsResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.notificationsResource = ApiService.listRepoNotificationsAsResource(params).get(
|
||||||
|
function(resp) {
|
||||||
|
var notifications = [];
|
||||||
|
resp.notifications.forEach(function(notification) {
|
||||||
|
if (notification.event == $scope.eventFilter) {
|
||||||
|
notifications.push(notification);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.notifications = notifications;
|
||||||
|
$scope.hasEvents = !!notifications.length;
|
||||||
|
return $scope.notifications;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.$watch('repository', loadNotifications);
|
||||||
|
$scope.$watch('isEnabled', loadNotifications);
|
||||||
|
$scope.$watch('eventFilter', loadNotifications);
|
||||||
|
|
||||||
|
// Watch _notificationCounter, which is set by create-external-notification-dialog. We use this
|
||||||
|
// to invalidate and reload.
|
||||||
|
$scope.$watch('repository._notificationCounter', function() {
|
||||||
|
$scope.notificationsResource = null;
|
||||||
|
loadNotifications();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadNotifications();
|
||||||
|
|
||||||
|
$scope.findEnumValue = function(values, index) {
|
||||||
|
var found = null;
|
||||||
|
Object.keys(values).forEach(function(key) {
|
||||||
|
if (values[key]['index'] == index) {
|
||||||
|
found = values[key];
|
||||||
|
return
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return found
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getEventInfo = function(notification) {
|
||||||
|
return ExternalNotificationData.getEventInfo(notification.event);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getMethodInfo = function(notification) {
|
||||||
|
return ExternalNotificationData.getMethodInfo(notification.method);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -13,11 +13,29 @@ angular.module('quay').directive('repositoryEventsTable', function () {
|
||||||
'repository': '=repository',
|
'repository': '=repository',
|
||||||
'isEnabled': '=isEnabled'
|
'isEnabled': '=isEnabled'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, ApiService, Restangular, UtilService, ExternalNotificationData) {
|
controller: function($scope, $element, $timeout, ApiService, Restangular, UtilService, ExternalNotificationData, $location) {
|
||||||
$scope.showNewNotificationCounter = 0;
|
$scope.showNewNotificationCounter = 0;
|
||||||
|
$scope.newNotificationData = {};
|
||||||
|
|
||||||
var loadNotifications = function() {
|
var loadNotifications = function() {
|
||||||
if (!$scope.repository || $scope.notificationsResource || !$scope.isEnabled) { return; }
|
if (!$scope.repository || !$scope.isEnabled) { return; }
|
||||||
|
|
||||||
|
var add_event = $location.search()['add_event'];
|
||||||
|
if (add_event) {
|
||||||
|
$timeout(function() {
|
||||||
|
$scope.newNotificationData = {
|
||||||
|
'currentEvent': ExternalNotificationData.getEventInfo(add_event)
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.askCreateNotification();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
$location.search('add_event', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope.notificationsResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var params = {
|
var params = {
|
||||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||||
|
@ -43,6 +61,18 @@ angular.module('quay').directive('repositoryEventsTable', function () {
|
||||||
$scope.showNewNotificationCounter++;
|
$scope.showNewNotificationCounter++;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.findEnumValue = function(values, index) {
|
||||||
|
var found = null;
|
||||||
|
Object.keys(values).forEach(function(key) {
|
||||||
|
if (values[key]['index'] == index) {
|
||||||
|
found = values[key];
|
||||||
|
return
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return found
|
||||||
|
};
|
||||||
|
|
||||||
$scope.getEventInfo = function(notification) {
|
$scope.getEventInfo = function(notification) {
|
||||||
return ExternalNotificationData.getEventInfo(notification.event);
|
return ExternalNotificationData.getEventInfo(notification.event);
|
||||||
};
|
};
|
||||||
|
@ -61,6 +91,13 @@ angular.module('quay').directive('repositoryEventsTable', function () {
|
||||||
var index = $.inArray(notification, $scope.notifications);
|
var index = $.inArray(notification, $scope.notifications);
|
||||||
if (index < 0) { return; }
|
if (index < 0) { return; }
|
||||||
$scope.notifications.splice(index, 1);
|
$scope.notifications.splice(index, 1);
|
||||||
|
|
||||||
|
if (!$scope.repository._notificationCounter) {
|
||||||
|
$scope.repository._notificationCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.repository._notificationCounter++;
|
||||||
|
|
||||||
}, ApiService.errorDisplay('Cannot delete notification'));
|
}, ApiService.errorDisplay('Cannot delete notification'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
18
static/js/directives/ui/vulnerability-priority-view.js
Normal file
18
static/js/directives/ui/vulnerability-priority-view.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* An element which displays a priority triangle for vulnerabilities.
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('vulnerabilityPriorityView', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/vulnerability-priority-view.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'priority': '=priority'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -10,11 +10,16 @@
|
||||||
})
|
})
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService) {
|
function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService, VulnerabilityService, Features) {
|
||||||
var namespace = $routeParams.namespace;
|
var namespace = $routeParams.namespace;
|
||||||
var name = $routeParams.name;
|
var name = $routeParams.name;
|
||||||
var imageid = $routeParams.image;
|
var imageid = $routeParams.image;
|
||||||
|
|
||||||
|
$scope.options = {
|
||||||
|
'vulnFilter': '',
|
||||||
|
'packageFilter': ''
|
||||||
|
};
|
||||||
|
|
||||||
var loadImage = function() {
|
var loadImage = function() {
|
||||||
var params = {
|
var params = {
|
||||||
'repository': namespace + '/' + name,
|
'repository': namespace + '/' + name,
|
||||||
|
@ -40,6 +45,46 @@
|
||||||
loadImage();
|
loadImage();
|
||||||
loadRepository();
|
loadRepository();
|
||||||
|
|
||||||
|
$scope.downloadPackages = function() {
|
||||||
|
if (!Features.SECURITY_SCANNER || $scope.packagesResource) { return; }
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'repository': namespace + '/' + name,
|
||||||
|
'imageid': imageid
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.packagesResource = ApiService.getRepoImagePackagesAsResource(params).get(function(packages) {
|
||||||
|
$scope.packages = packages;
|
||||||
|
return packages;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.loadImageVulnerabilities = function() {
|
||||||
|
if (!Features.SECURITY_SCANNER || $scope.vulnerabilitiesResource) { return; }
|
||||||
|
|
||||||
|
$scope.VulnerabilityLevels = VulnerabilityService.getLevels();
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'repository': namespace + '/' + name,
|
||||||
|
'imageid': imageid
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.vulnerabilitiesResource = ApiService.getRepoImageVulnerabilitiesAsResource(params).get(function(resp) {
|
||||||
|
$scope.vulnerabilityInfo = resp;
|
||||||
|
$scope.vulnerabilities = [];
|
||||||
|
|
||||||
|
if (resp.data && resp.data.Vulnerabilities) {
|
||||||
|
resp.data.Vulnerabilities.forEach(function(vuln) {
|
||||||
|
vuln_copy = jQuery.extend({}, vuln);
|
||||||
|
vuln_copy['index'] = VulnerabilityService.LEVELS[vuln['Priority']]['index'];
|
||||||
|
$scope.vulnerabilities.push(vuln_copy);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$scope.downloadChanges = function() {
|
$scope.downloadChanges = function() {
|
||||||
if ($scope.changesResource) { return; }
|
if ($scope.changesResource) { return; }
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
var imageLoader = ImageLoaderService.getLoader($scope.namespace, $scope.name);
|
var imageLoader = ImageLoaderService.getLoader($scope.namespace, $scope.name);
|
||||||
|
|
||||||
// Tab-enabled counters.
|
// Tab-enabled counters.
|
||||||
|
$scope.infoShown = 0;
|
||||||
$scope.tagsShown = 0;
|
$scope.tagsShown = 0;
|
||||||
$scope.logsShown = 0;
|
$scope.logsShown = 0;
|
||||||
$scope.buildsShown = 0;
|
$scope.buildsShown = 0;
|
||||||
|
@ -119,6 +120,10 @@
|
||||||
$scope.viewScope.selectedTags = $.unique(tagNames.split(','));
|
$scope.viewScope.selectedTags = $.unique(tagNames.split(','));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.showInfo = function() {
|
||||||
|
$scope.infoShown++;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.showBuilds = function() {
|
$scope.showBuilds = function() {
|
||||||
$scope.buildsShown++;
|
$scope.buildsShown++;
|
||||||
};
|
};
|
||||||
|
|
|
@ -47,12 +47,12 @@ function(Config, Features, VulnerabilityService) {
|
||||||
events.push({
|
events.push({
|
||||||
'id': 'vulnerability_found',
|
'id': 'vulnerability_found',
|
||||||
'title': 'Package Vulnerability Found',
|
'title': 'Package Vulnerability Found',
|
||||||
'icon': 'fa-flag',
|
'icon': 'fa-bug',
|
||||||
'fields': [
|
'fields': [
|
||||||
{
|
{
|
||||||
'name': 'level',
|
'name': 'level',
|
||||||
'type': 'enum',
|
'type': 'enum',
|
||||||
'title': 'Minimum Severity Level',
|
'title': 'Minimum Priority Level',
|
||||||
'values': VulnerabilityService.LEVELS,
|
'values': VulnerabilityService.LEVELS,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -129,7 +129,8 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
|
||||||
'message': 'A {vulnerability.priority} vulnerability was detected in repository {repository}',
|
'message': 'A {vulnerability.priority} vulnerability was detected in repository {repository}',
|
||||||
'page': function(metadata) {
|
'page': function(metadata) {
|
||||||
return '/repository/' + metadata.repository + '?tab=tags';
|
return '/repository/' + metadata.repository + '?tab=tags';
|
||||||
}
|
},
|
||||||
|
'dismissable': true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,89 +3,7 @@
|
||||||
*/
|
*/
|
||||||
angular.module('quay').factory('VulnerabilityService', ['Config', function(Config) {
|
angular.module('quay').factory('VulnerabilityService', ['Config', function(Config) {
|
||||||
var vulnService = {};
|
var vulnService = {};
|
||||||
|
vulnService.LEVELS = window.__vuln_priority;
|
||||||
// NOTE: This objects are used directly in the external-notification-data service, so make sure
|
|
||||||
// to update that code if the format here is changed.
|
|
||||||
vulnService.LEVELS = {
|
|
||||||
'Unknown': {
|
|
||||||
'title': 'Unknown',
|
|
||||||
'index': '6',
|
|
||||||
'level': 'info',
|
|
||||||
|
|
||||||
'description': 'Unknown is either a security problem that has not been assigned ' +
|
|
||||||
'to a priority yet or a priority that our system did not recognize',
|
|
||||||
'banner_required': false
|
|
||||||
},
|
|
||||||
|
|
||||||
'Negligible': {
|
|
||||||
'title': 'Negligible',
|
|
||||||
'index': '5',
|
|
||||||
'level': 'info',
|
|
||||||
|
|
||||||
'description': 'Negligible is technically a security problem, but is only theoretical ' +
|
|
||||||
'in nature, requires a very special situation, has almost no install base, ' +
|
|
||||||
'or does no real damage.',
|
|
||||||
'banner_required': false
|
|
||||||
},
|
|
||||||
|
|
||||||
'Low': {
|
|
||||||
'title': 'Low',
|
|
||||||
'index': '4',
|
|
||||||
'level': 'warning',
|
|
||||||
|
|
||||||
'description': 'Low is a security problem, but is hard to exploit due to environment, ' +
|
|
||||||
'requires a user-assisted attack, a small install base, or does very ' +
|
|
||||||
'little damage.',
|
|
||||||
'banner_required': false
|
|
||||||
},
|
|
||||||
|
|
||||||
'Medium': {
|
|
||||||
'title': 'Medium',
|
|
||||||
'value': 'Medium',
|
|
||||||
'index': '3',
|
|
||||||
'level': 'warning',
|
|
||||||
|
|
||||||
'description': 'Medium is a real security problem, and is exploitable for many people. ' +
|
|
||||||
'Includes network daemon denial of service attacks, cross-site scripting, ' +
|
|
||||||
'and gaining user privileges.',
|
|
||||||
'banner_required': false
|
|
||||||
},
|
|
||||||
|
|
||||||
'High': {
|
|
||||||
'title': 'High',
|
|
||||||
'value': 'High',
|
|
||||||
'index': '2',
|
|
||||||
'level': 'warning',
|
|
||||||
|
|
||||||
'description': 'High is a real problem, exploitable for many people in a default installation. ' +
|
|
||||||
'Includes serious remote denial of services, local root privilege escalations, ' +
|
|
||||||
'or data loss.',
|
|
||||||
'banner_required': false
|
|
||||||
},
|
|
||||||
|
|
||||||
'Critical': {
|
|
||||||
'title': 'Critical',
|
|
||||||
'value': 'Critical',
|
|
||||||
'index': '1',
|
|
||||||
'level': 'error',
|
|
||||||
|
|
||||||
'description': 'Critical is a world-burning problem, exploitable for nearly all people in ' +
|
|
||||||
'a installation of the package. Includes remote root privilege escalations, ' +
|
|
||||||
'or massive data loss.',
|
|
||||||
'banner_required': true
|
|
||||||
},
|
|
||||||
|
|
||||||
'Defcon1': {
|
|
||||||
'title': 'Defcon 1',
|
|
||||||
'value': 'Defcon1',
|
|
||||||
'index': '0',
|
|
||||||
'level': 'error',
|
|
||||||
|
|
||||||
'description': 'Defcon1 is a Critical problem which has been manually highlighted ' +
|
|
||||||
'by the Quay team. It requires immediate attention.',
|
|
||||||
'banner_required': true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
vulnService.getLevels = function() {
|
vulnService.getLevels = function() {
|
||||||
return Object.keys(vulnService.LEVELS).map(function(key) {
|
return Object.keys(vulnService.LEVELS).map(function(key) {
|
||||||
|
|
|
@ -25,6 +25,16 @@
|
||||||
tab-init="downloadChanges()">
|
tab-init="downloadChanges()">
|
||||||
<i class="fa fa-code-fork"></i>
|
<i class="fa fa-code-fork"></i>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="cor-tab" tab-title="Security Scan" tab-target="#security"
|
||||||
|
tab-init="loadImageVulnerabilities()"
|
||||||
|
quay-show="Features.SECURITY_SCANNER">
|
||||||
|
<i class="fa fa-bug"></i>
|
||||||
|
</span>
|
||||||
|
<span class="cor-tab" tab-title="Packages" tab-target="#packages"
|
||||||
|
tab-init="downloadPackages()"
|
||||||
|
quay-show="Features.SECURITY_SCANNER">
|
||||||
|
<i class="fa ci-package"></i>
|
||||||
|
</span>
|
||||||
</div> <!-- /cor-tabs -->
|
</div> <!-- /cor-tabs -->
|
||||||
|
|
||||||
<div class="cor-tab-content">
|
<div class="cor-tab-content">
|
||||||
|
@ -53,6 +63,118 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Security -->
|
||||||
|
<div id="security" class="tab-pane" quay-require="['SECURITY_SCANNER']">
|
||||||
|
<div class="resource-view" resource="vulnerabilitiesResource" error-message="'Could not load security information for image'">
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="filter-box floating" collection="vulnerabilities" filter-model="options.vulnFilter" filter-name="Vulnerabilities" ng-if="vulnerabilityInfo.status == 'scanned' && vulnerabilities.length"></div>
|
||||||
|
|
||||||
|
<h3>Image Security</h3>
|
||||||
|
<div class="empty" ng-if="vulnerabilityInfo.status == 'queued'">
|
||||||
|
<div class="empty-primary-msg">This image has not been indexed yet</div>
|
||||||
|
<div class="empty-secondary-msg">
|
||||||
|
Please try again in a few minutes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty" ng-if="vulnerabilityInfo.status == 'failed'">
|
||||||
|
<div class="empty-primary-msg">This image could not be indexed</div>
|
||||||
|
<div class="empty-secondary-msg">
|
||||||
|
Our security scanner was unable to index this image.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty" ng-if="vulnerabilityInfo.status == 'scanned' && !vulnerabilities.length">
|
||||||
|
<div class="empty-primary-msg">This image contains no recognized security vulnerabilities</div>
|
||||||
|
<div class="empty-secondary-msg">
|
||||||
|
Quay currently indexes Debian, Red Hat and Ubuntu packages.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-if="vulnerabilityInfo.status == 'scanned' && vulnerabilities.length">
|
||||||
|
<table class="co-table">
|
||||||
|
<thead>
|
||||||
|
<td style="width: 200px;">Vulnerability</td>
|
||||||
|
<td style="width: 200px;">Priority</td>
|
||||||
|
<td>Description</td>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tr ng-repeat="vulnerability in vulnerabilities | filter:options.vulnFilter | orderBy:'index'">
|
||||||
|
<td><a href="{{ vulnerability.Link }}" target="_blank">{{ vulnerability.ID }}</a></td>
|
||||||
|
<td>
|
||||||
|
<span class="vulnerability-priority-view" priority="vulnerability.Priority"></span>
|
||||||
|
<td>{{ vulnerability.Description }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="empty" ng-if="(vulnerabilities | filter:options.vulnFilter).length == 0"
|
||||||
|
style="margin-top: 20px;">
|
||||||
|
<div class="empty-primary-msg">No matching vulnerabilities found</div>
|
||||||
|
<div class="empty-secondary-msg">
|
||||||
|
Please adjust your filter above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-col col-md-3 hidden-sm hidden-xs">
|
||||||
|
<h4>Priority Guide</h4>
|
||||||
|
<ul class="levels">
|
||||||
|
<li ng-repeat="level in VulnerabilityLevels | orderBy: 'index'">
|
||||||
|
<div class="vulnerability-priority-view" priority="level.title"></div>
|
||||||
|
<div class="description">
|
||||||
|
{{ level.description }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Packages -->
|
||||||
|
<div id="packages" class="tab-pane" quay-require="['SECURITY_SCANNER']">
|
||||||
|
<div class="resource-view" resource="packagesResource" error-message="'Could not load image packages'">
|
||||||
|
<div class="filter-box floating" collection="packages.data.Packages" filter-model="options.packageFilter" filter-name="Packages" ng-if="packages.status == 'scanned' && packages.data.Packages.length"></div>
|
||||||
|
|
||||||
|
<h3>Image Packages</h3>
|
||||||
|
<div class="empty" ng-if="packages.status == 'queued'">
|
||||||
|
<div class="empty-primary-msg">This image has not been indexed yet</div>
|
||||||
|
<div class="empty-secondary-msg">
|
||||||
|
Please try again in a few minutes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty" ng-if="packages.status == 'failed'">
|
||||||
|
<div class="empty-primary-msg">This image could not be indexed</div>
|
||||||
|
<div class="empty-secondary-msg">
|
||||||
|
Our security scanner was unable to index this image.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="co-table" ng-if="packages.status == 'scanned'">
|
||||||
|
<thead>
|
||||||
|
<td>Package Name</td>
|
||||||
|
<td>Package Version</td>
|
||||||
|
<td>OS</td>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tr ng-repeat="package in packages.data.Packages | filter:options.packageFilter | orderBy:'Name'">
|
||||||
|
<td>{{ package.Name }}</td>
|
||||||
|
<td>{{ package.Version }}</td>
|
||||||
|
<td>{{ package.OS }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="empty" ng-if="(packages.data.Packages | filter:options.packageFilter).length == 0"
|
||||||
|
style="margin-top: 20px;">
|
||||||
|
<div class="empty-primary-msg">No matching packages found</div>
|
||||||
|
<div class="empty-secondary-msg" ng-if="options.packageFilter">
|
||||||
|
Please adjust your filter above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,7 +17,8 @@
|
||||||
|
|
||||||
<div class="cor-tab-panel">
|
<div class="cor-tab-panel">
|
||||||
<div class="cor-tabs">
|
<div class="cor-tabs">
|
||||||
<span class="cor-tab" tab-active="true" tab-title="Information" tab-target="#info">
|
<span class="cor-tab" tab-active="true" tab-title="Information" tab-target="#info"
|
||||||
|
tab-init="showInfo()">
|
||||||
<i class="fa fa-info-circle"></i>
|
<i class="fa fa-info-circle"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
@ -56,7 +57,8 @@
|
||||||
<div id="info" class="tab-pane active">
|
<div id="info" class="tab-pane active">
|
||||||
<div class="repo-panel-info"
|
<div class="repo-panel-info"
|
||||||
repository="viewScope.repository"
|
repository="viewScope.repository"
|
||||||
builds="viewScope.builds"></div>
|
builds="viewScope.builds"
|
||||||
|
is-enabled="infoShown"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
|
|
|
@ -38,6 +38,7 @@
|
||||||
window.__config = {{ config_set|safe }};
|
window.__config = {{ config_set|safe }};
|
||||||
window.__oauth = {{ oauth_set|safe }};
|
window.__oauth = {{ oauth_set|safe }};
|
||||||
window.__auth_scopes = {{ scope_set|safe }};
|
window.__auth_scopes = {{ scope_set|safe }};
|
||||||
|
window.__vuln_priority = {{ vuln_priority_set|safe }}
|
||||||
window.__token = '{{ csrf_token() }}';
|
window.__token = '{{ csrf_token() }}';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -106,7 +107,6 @@ mixpanel.init("{{ mixpanel_key }}", { track_pageview : false, debug: {{ is_debug
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<ul>
|
<ul>
|
||||||
<li><span class="copyright">©2015 CoreOS, Inc.</span></li>
|
<li><span class="copyright">©2015 CoreOS, Inc.</span></li>
|
||||||
<li><a href="http://blog.quay.io">Blog</a></li>
|
|
||||||
<li quay-require="['BILLING']"><a href="/tos" target="_self">Terms</a></li>
|
<li quay-require="['BILLING']"><a href="/tos" target="_self">Terms</a></li>
|
||||||
<li quay-require="['BILLING']"><a href="/privacy" target="_self">Privacy</a></li>
|
<li quay-require="['BILLING']"><a href="/privacy" target="_self">Privacy</a></li>
|
||||||
<li quay-require="['BILLING']"><a href="/security/" target="_self">Security</a></li>
|
<li quay-require="['BILLING']"><a href="/security/" target="_self">Security</a></li>
|
||||||
|
|
Binary file not shown.
|
@ -49,8 +49,7 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
|
||||||
SuperUserSendRecoveryEmail, ChangeLog,
|
SuperUserSendRecoveryEmail, ChangeLog,
|
||||||
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
||||||
SuperUserAggregateLogs)
|
SuperUserAggregateLogs)
|
||||||
|
from endpoints.api.secscan import RepositoryImagePackages, RepositoryImageVulnerabilities
|
||||||
from endpoints.api.secscan import RepositoryImagePackages, RepositoryTagVulnerabilities
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -4225,10 +4224,10 @@ class TestOrganizationInvoiceField(ApiTestCase):
|
||||||
self._run_test('DELETE', 201, 'devtable', None)
|
self._run_test('DELETE', 201, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
class TestRepositoryTagVulnerabilities(ApiTestCase):
|
class TestRepositoryImageVulnerabilities(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
self._set_url(RepositoryTagVulnerabilities, repository='devtable/simple', tag='latest')
|
self._set_url(RepositoryImageVulnerabilities, repository='devtable/simple', imageid='fake')
|
||||||
|
|
||||||
def test_get_anonymous(self):
|
def test_get_anonymous(self):
|
||||||
self._run_test('GET', 401, None, None)
|
self._run_test('GET', 401, None, None)
|
||||||
|
@ -4240,7 +4239,7 @@ class TestRepositoryTagVulnerabilities(ApiTestCase):
|
||||||
self._run_test('GET', 403, 'reader', None)
|
self._run_test('GET', 403, 'reader', None)
|
||||||
|
|
||||||
def test_get_devtable(self):
|
def test_get_devtable(self):
|
||||||
self._run_test('GET', 200, 'devtable', None)
|
self._run_test('GET', 404, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
class TestRepositoryImagePackages(ApiTestCase):
|
class TestRepositoryImagePackages(ApiTestCase):
|
||||||
|
|
|
@ -126,6 +126,7 @@ def yield_random_entries(batch_query, primary_key_field, batch_size, max_id):
|
||||||
an "id" field which can be inspected.
|
an "id" field which can be inspected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
max_id = max(max_id, 1)
|
||||||
allocator = CompletedKeys(max_id + 1)
|
allocator = CompletedKeys(max_id + 1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
224
util/secscan/api.py
Normal file
224
util/secscan/api.py
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
import features
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from data.database import CloseForLongOperation
|
||||||
|
from urlparse import urljoin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# NOTE: This objects are used directly in the external-notification-data and vulnerability-service
|
||||||
|
# on the frontend, so be careful with changing their existing keys.
|
||||||
|
PRIORITY_LEVELS = {
|
||||||
|
'Unknown': {
|
||||||
|
'title': 'Unknown',
|
||||||
|
'index': '6',
|
||||||
|
'level': 'info',
|
||||||
|
|
||||||
|
'description': 'Unknown is either a security problem that has not been assigned ' +
|
||||||
|
'to a priority yet or a priority that our system did not recognize',
|
||||||
|
'banner_required': False
|
||||||
|
},
|
||||||
|
|
||||||
|
'Negligible': {
|
||||||
|
'title': 'Negligible',
|
||||||
|
'index': '5',
|
||||||
|
'level': 'info',
|
||||||
|
|
||||||
|
'description': 'Negligible is technically a security problem, but is only theoretical ' +
|
||||||
|
'in nature, requires a very special situation, has almost no install base, ' +
|
||||||
|
'or does no real damage.',
|
||||||
|
'banner_required': False
|
||||||
|
},
|
||||||
|
|
||||||
|
'Low': {
|
||||||
|
'title': 'Low',
|
||||||
|
'index': '4',
|
||||||
|
'level': 'warning',
|
||||||
|
|
||||||
|
'description': 'Low is a security problem, but is hard to exploit due to environment, ' +
|
||||||
|
'requires a user-assisted attack, a small install base, or does very ' +
|
||||||
|
'little damage.',
|
||||||
|
'banner_required': False
|
||||||
|
},
|
||||||
|
|
||||||
|
'Medium': {
|
||||||
|
'title': 'Medium',
|
||||||
|
'value': 'Medium',
|
||||||
|
'index': '3',
|
||||||
|
'level': 'warning',
|
||||||
|
|
||||||
|
'description': 'Medium is a real security problem, and is exploitable for many people. ' +
|
||||||
|
'Includes network daemon denial of service attacks, cross-site scripting, ' +
|
||||||
|
'and gaining user privileges.',
|
||||||
|
'banner_required': False
|
||||||
|
},
|
||||||
|
|
||||||
|
'High': {
|
||||||
|
'title': 'High',
|
||||||
|
'value': 'High',
|
||||||
|
'index': '2',
|
||||||
|
'level': 'warning',
|
||||||
|
|
||||||
|
'description': 'High is a real problem, exploitable for many people in a default installation. ' +
|
||||||
|
'Includes serious remote denial of services, local root privilege escalations, ' +
|
||||||
|
'or data loss.',
|
||||||
|
'banner_required': False
|
||||||
|
},
|
||||||
|
|
||||||
|
'Critical': {
|
||||||
|
'title': 'Critical',
|
||||||
|
'value': 'Critical',
|
||||||
|
'index': '1',
|
||||||
|
'level': 'error',
|
||||||
|
|
||||||
|
'description': 'Critical is a world-burning problem, exploitable for nearly all people in ' +
|
||||||
|
'a installation of the package. Includes remote root privilege escalations, ' +
|
||||||
|
'or massive data loss.',
|
||||||
|
'banner_required': True
|
||||||
|
},
|
||||||
|
|
||||||
|
'Defcon1': {
|
||||||
|
'title': 'Defcon 1',
|
||||||
|
'value': 'Defcon1',
|
||||||
|
'index': '0',
|
||||||
|
'level': 'error',
|
||||||
|
|
||||||
|
'description': 'Defcon1 is a Critical problem which has been manually highlighted ' +
|
||||||
|
'by the Quay team. It requires immediate attention.',
|
||||||
|
'banner_required': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_priority_for_index(index):
|
||||||
|
for priority in PRIORITY_LEVELS:
|
||||||
|
if PRIORITY_LEVELS[priority]['index'] == index:
|
||||||
|
return priority
|
||||||
|
|
||||||
|
return 'Unknown'
|
||||||
|
|
||||||
|
class SecurityConfigValidator(object):
|
||||||
|
def __init__(self, app, config_provider):
|
||||||
|
self._config_provider = config_provider
|
||||||
|
|
||||||
|
if not features.SECURITY_SCANNER:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._security_config = app.config['SECURITY_SCANNER']
|
||||||
|
if self._security_config is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._certificate = self._get_filepath('CA_CERTIFICATE_FILENAME') or False
|
||||||
|
self._public_key = self._get_filepath('PUBLIC_KEY_FILENAME')
|
||||||
|
self._private_key = self._get_filepath('PRIVATE_KEY_FILENAME')
|
||||||
|
|
||||||
|
if self._public_key and self._private_key:
|
||||||
|
self._keys = (self._public_key, self._private_key)
|
||||||
|
else:
|
||||||
|
self._keys = None
|
||||||
|
|
||||||
|
def _get_filepath(self, key):
|
||||||
|
config = self._security_config
|
||||||
|
|
||||||
|
if key in config:
|
||||||
|
with self._config_provider.get_volume_file(config[key]) as f:
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cert(self):
|
||||||
|
return self._certificate
|
||||||
|
|
||||||
|
def keypair(self):
|
||||||
|
return self._keys
|
||||||
|
|
||||||
|
def valid(self):
|
||||||
|
if not features.SECURITY_SCANNER:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._security_config:
|
||||||
|
logger.debug('Missing SECURITY_SCANNER block in configuration')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not 'ENDPOINT' in self._security_config:
|
||||||
|
logger.debug('Missing ENDPOINT field in SECURITY_SCANNER configuration')
|
||||||
|
return False
|
||||||
|
|
||||||
|
endpoint = self._security_config['ENDPOINT'] or ''
|
||||||
|
if not endpoint.startswith('http://') and not endpoint.startswith('https://'):
|
||||||
|
logger.debug('ENDPOINT field in SECURITY_SCANNER configuration must start with http or https')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if endpoint.startswith('https://') and (self._certificate is False or self._keys is None):
|
||||||
|
logger.debug('Certificate and key pair required for talking to security worker over HTTPS')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityScannerAPI(object):
|
||||||
|
""" Helper class for talking to the Security Scan service (Clair). """
|
||||||
|
def __init__(self, app, config_provider):
|
||||||
|
self.app = app
|
||||||
|
self.config_provider = config_provider
|
||||||
|
self._security_config = None
|
||||||
|
|
||||||
|
config_validator = SecurityConfigValidator(app, config_provider)
|
||||||
|
if not config_validator.valid():
|
||||||
|
logger.warning('Invalid config provided to SecurityScannerAPI')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._security_config = app.config.get('SECURITY_SCANNER')
|
||||||
|
self._certificate = config_validator.cert()
|
||||||
|
self._keys = config_validator.keypair()
|
||||||
|
|
||||||
|
def check_layer_vulnerable(self, layer_id, cve_id):
|
||||||
|
""" Checks with Clair whether the given layer is vulnerable to the given CVE. """
|
||||||
|
try:
|
||||||
|
body = {
|
||||||
|
'LayersIDs': [layer_id]
|
||||||
|
}
|
||||||
|
response = self.call('vulnerabilities/%s/affected-layers', body, cve_id)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
logger.exception('Got exception when trying to call Clair endpoint')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.exception('Got exception when trying to parse Clair response')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (not layer_id in response_data or
|
||||||
|
not response_data[layer_id].get('Vulnerable', False)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def call(self, relative_url, body=None, *args, **kwargs):
|
||||||
|
""" Issues an HTTP call to the sec API at the given relative URL.
|
||||||
|
This function disconnects from the database while awaiting a response
|
||||||
|
from the API server.
|
||||||
|
"""
|
||||||
|
security_config = self._security_config
|
||||||
|
if security_config is None:
|
||||||
|
raise Exception('Cannot call unconfigured security system')
|
||||||
|
|
||||||
|
api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/'
|
||||||
|
url = urljoin(api_url, relative_url % args)
|
||||||
|
|
||||||
|
client = self.app.config['HTTPCLIENT']
|
||||||
|
timeout = security_config.get('API_TIMEOUT_SECONDS', 1)
|
||||||
|
logger.debug('Looking up sec information: %s', url)
|
||||||
|
|
||||||
|
with CloseForLongOperation(self.app.config):
|
||||||
|
if body is not None:
|
||||||
|
return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self._keys,
|
||||||
|
verify=self._certificate)
|
||||||
|
else:
|
||||||
|
return client.get(url, params=kwargs, timeout=timeout, cert=self._keys,
|
||||||
|
verify=self._certificate)
|
|
@ -1,50 +0,0 @@
|
||||||
import features
|
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
|
|
||||||
from urlparse import urljoin
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class SecurityScanEndpoint(object):
|
|
||||||
""" Helper class for talking to the Security Scan service (Clair). """
|
|
||||||
def __init__(self, app, config_provider):
|
|
||||||
self.app = app
|
|
||||||
self.config_provider = config_provider
|
|
||||||
|
|
||||||
if not features.SECURITY_SCANNER:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.security_config = app.config['SECURITY_SCANNER']
|
|
||||||
|
|
||||||
self.certificate = self._getfilepath('CA_CERTIFICATE_FILENAME') or False
|
|
||||||
self.public_key = self._getfilepath('PUBLIC_KEY_FILENAME')
|
|
||||||
self.private_key = self._getfilepath('PRIVATE_KEY_FILENAME')
|
|
||||||
|
|
||||||
if self.public_key and self.private_key:
|
|
||||||
self.keys = (self.public_key, self.private_key)
|
|
||||||
else:
|
|
||||||
self.keys = None
|
|
||||||
|
|
||||||
def _getfilepath(self, config_key):
|
|
||||||
security_config = self.security_config
|
|
||||||
|
|
||||||
if config_key in security_config:
|
|
||||||
with self.config_provider.get_volume_file(security_config[config_key]) as f:
|
|
||||||
return f.name
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def call_api(self, relative_url, *args, **kwargs):
|
|
||||||
""" Issues an HTTP call to the sec API at the given relative URL. """
|
|
||||||
security_config = self.security_config
|
|
||||||
api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/'
|
|
||||||
url = urljoin(api_url, relative_url % args)
|
|
||||||
|
|
||||||
client = self.app.config['HTTPCLIENT']
|
|
||||||
timeout = security_config.get('API_TIMEOUT_SECONDS', 1)
|
|
||||||
logger.debug('Looking up sec information: %s', url)
|
|
||||||
|
|
||||||
return client.get(url, params=kwargs, timeout=timeout, cert=self.keys,
|
|
||||||
verify=self.certificate)
|
|
2
web.py
2
web.py
|
@ -11,6 +11,7 @@ from endpoints.oauthlogin import oauthlogin
|
||||||
from endpoints.githubtrigger import githubtrigger
|
from endpoints.githubtrigger import githubtrigger
|
||||||
from endpoints.gitlabtrigger import gitlabtrigger
|
from endpoints.gitlabtrigger import gitlabtrigger
|
||||||
from endpoints.bitbuckettrigger import bitbuckettrigger
|
from endpoints.bitbuckettrigger import bitbuckettrigger
|
||||||
|
from endpoints.secscan import secscan
|
||||||
|
|
||||||
if os.environ.get('DEBUGLOG') == 'true':
|
if os.environ.get('DEBUGLOG') == 'true':
|
||||||
logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False)
|
logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False)
|
||||||
|
@ -23,3 +24,4 @@ application.register_blueprint(bitbuckettrigger, url_prefix='/oauth1')
|
||||||
application.register_blueprint(api_bp, url_prefix='/api')
|
application.register_blueprint(api_bp, url_prefix='/api')
|
||||||
application.register_blueprint(webhooks, url_prefix='/webhooks')
|
application.register_blueprint(webhooks, url_prefix='/webhooks')
|
||||||
application.register_blueprint(realtime, url_prefix='/realtime')
|
application.register_blueprint(realtime, url_prefix='/realtime')
|
||||||
|
application.register_blueprint(secscan, url_prefix='/secscan')
|
||||||
|
|
|
@ -34,6 +34,7 @@ class NotificationWorker(QueueWorker):
|
||||||
logger.exception('Cannot find notification event: %s', ex.message)
|
logger.exception('Cannot find notification event: %s', ex.message)
|
||||||
raise JobException('Cannot find notification event: %s' % ex.message)
|
raise JobException('Cannot find notification event: %s' % ex.message)
|
||||||
|
|
||||||
|
if event_handler.should_perform(job_details['event_data'], notification):
|
||||||
method_handler.perform(notification, event_handler, job_details)
|
method_handler.perform(notification, event_handler, job_details)
|
||||||
|
|
||||||
|
|
||||||
|
|
91
workers/security_notification_worker.py
Normal file
91
workers/security_notification_worker.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import features
|
||||||
|
|
||||||
|
from app import secscan_notification_queue, secscan_api
|
||||||
|
from data import model
|
||||||
|
from data.database import (Image, ImageStorage, ExternalNotificationEvent,
|
||||||
|
Repository, RepositoryNotification, RepositoryTag)
|
||||||
|
from endpoints.notificationhelper import spawn_notification
|
||||||
|
from workers.queueworker import QueueWorker
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityNotificationWorker(QueueWorker):
|
||||||
|
def process_queue_item(self, data):
|
||||||
|
cve_id = data['Name']
|
||||||
|
vulnerability = data['Content']['Vulnerability']
|
||||||
|
priority = vulnerability['Priority']
|
||||||
|
|
||||||
|
# Lookup the external event for when we have vulnerabilities.
|
||||||
|
event = ExternalNotificationEvent.get(name='vulnerability_found')
|
||||||
|
|
||||||
|
# For each layer, retrieving the matching tags and join with repository to determine which
|
||||||
|
# require new notifications.
|
||||||
|
tag_map = defaultdict(set)
|
||||||
|
repository_map = {}
|
||||||
|
|
||||||
|
# Find all tags that contain the layer(s) introducing the vulnerability.
|
||||||
|
content = data['Content']
|
||||||
|
layer_ids = content.get('NewIntroducingLayersIDs', content.get('IntroducingLayersIDs', []))
|
||||||
|
for layer_id in layer_ids:
|
||||||
|
(docker_image_id, storage_uuid) = layer_id.split('.', 2)
|
||||||
|
tags = model.tag.get_matching_tags(docker_image_id, storage_uuid, RepositoryTag,
|
||||||
|
Repository, Image, ImageStorage)
|
||||||
|
|
||||||
|
# Additionally filter to tags only in repositories that have the event setup.
|
||||||
|
matching = list(tags
|
||||||
|
.switch(RepositoryTag)
|
||||||
|
.join(Repository)
|
||||||
|
.join(RepositoryNotification)
|
||||||
|
.where(RepositoryNotification.event == event))
|
||||||
|
|
||||||
|
check_map = {}
|
||||||
|
for tag in matching:
|
||||||
|
# Verify that the tag's root image has the vulnerability.
|
||||||
|
tag_layer_id = '%s.%s' % (tag.image.docker_image_id, tag.image.storage.uuid)
|
||||||
|
logger.debug('Checking if layer %s is vulnerable to %s', tag_layer_id, cve_id)
|
||||||
|
|
||||||
|
if not tag_layer_id in check_map:
|
||||||
|
is_vulerable = secscan_api.check_layer_vulnerable(tag_layer_id, cve_id)
|
||||||
|
check_map[tag_layer_id] = is_vulerable
|
||||||
|
|
||||||
|
logger.debug('Result of layer %s is vulnerable to %s check: %s', tag_layer_id, cve_id,
|
||||||
|
check_map[tag_layer_id])
|
||||||
|
|
||||||
|
if check_map[tag_layer_id]:
|
||||||
|
# Add the vulnerable tag to the list.
|
||||||
|
tag_map[tag.repository_id].add(tag.name)
|
||||||
|
repository_map[tag.repository_id] = tag.repository
|
||||||
|
|
||||||
|
# For each of the tags found, issue a notification.
|
||||||
|
for repository_id in tag_map:
|
||||||
|
tags = tag_map[repository_id]
|
||||||
|
event_data = {
|
||||||
|
'tags': list(tags),
|
||||||
|
'vulnerability': {
|
||||||
|
'id': data['Name'],
|
||||||
|
'description': vulnerability['Description'],
|
||||||
|
'link': vulnerability['Link'],
|
||||||
|
'priority': priority,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_notification(repository_map[repository_id], 'vulnerability_found', event_data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not features.SECURITY_SCANNER:
|
||||||
|
logger.debug('Security scanner disabled; skipping SecurityNotificationWorker')
|
||||||
|
while True:
|
||||||
|
time.sleep(100000)
|
||||||
|
|
||||||
|
worker = SecurityNotificationWorker(secscan_notification_queue, poll_period_seconds=30,
|
||||||
|
reservation_seconds=30, retry_after_seconds=30)
|
||||||
|
worker.start()
|
|
@ -1,70 +1,96 @@
|
||||||
import logging
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import features
|
import features
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from sys import exc_info
|
from endpoints.notificationhelper import spawn_notification
|
||||||
|
from collections import defaultdict
|
||||||
from peewee import JOIN_LEFT_OUTER
|
from peewee import JOIN_LEFT_OUTER
|
||||||
from app import app, storage, OVERRIDE_CONFIG_DIRECTORY
|
from app import app, config_provider, storage, OVERRIDE_CONFIG_DIRECTORY, secscan_api
|
||||||
from workers.worker import Worker
|
from workers.worker import Worker
|
||||||
from data.database import Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect
|
from data.database import (Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement,
|
||||||
|
db_random_func, UseThenDisconnect, RepositoryTag, Repository,
|
||||||
|
ExternalNotificationEvent, RepositoryNotification)
|
||||||
|
from util.secscan.api import SecurityConfigValidator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BATCH_SIZE = 20
|
BATCH_SIZE = 20
|
||||||
INDEXING_INTERVAL = 10
|
INDEXING_INTERVAL = 10
|
||||||
API_METHOD_INSERT = '/layers'
|
API_METHOD_INSERT = '/v1/layers'
|
||||||
API_METHOD_VERSION = '/versions/engine'
|
API_METHOD_VERSION = '/v1/versions/engine'
|
||||||
|
|
||||||
def _get_image_to_export(version):
|
def _get_images_to_export_list(version):
|
||||||
Parent = Image.alias()
|
Parent = Image.alias()
|
||||||
ParentImageStorage = ImageStorage.alias()
|
ParentImageStorage = ImageStorage.alias()
|
||||||
rimages = []
|
rimages = []
|
||||||
|
|
||||||
# Without parent
|
# Collect the images without parents
|
||||||
candidates = (Image
|
candidates = (Image
|
||||||
.select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum)
|
.select(Image.id, Image.docker_image_id, ImageStorage.uuid)
|
||||||
.join(ImageStorage)
|
.join(ImageStorage)
|
||||||
.where(Image.security_indexed_engine < version, Image.parent_id >> None, ImageStorage.uploading == False, ImageStorage.checksum != '')
|
.where(Image.security_indexed_engine < version,
|
||||||
|
Image.parent_id >> None,
|
||||||
|
ImageStorage.uploading == False)
|
||||||
.limit(BATCH_SIZE*10)
|
.limit(BATCH_SIZE*10)
|
||||||
.alias('candidates'))
|
.alias('candidates'))
|
||||||
|
|
||||||
images = (Image
|
images = (Image
|
||||||
.select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum)
|
.select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid)
|
||||||
.from_(candidates)
|
.from_(candidates)
|
||||||
.order_by(db_random_func())
|
.order_by(db_random_func())
|
||||||
.tuples()
|
.tuples()
|
||||||
.limit(BATCH_SIZE))
|
.limit(BATCH_SIZE))
|
||||||
|
|
||||||
for image in images:
|
for image in images:
|
||||||
rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': None, 'parent_storage_uuid': None})
|
rimages.append({'image_id': image[0],
|
||||||
|
'docker_image_id': image[1],
|
||||||
|
'storage_uuid': image[2],
|
||||||
|
'parent_docker_image_id': None,
|
||||||
|
'parent_storage_uuid': None})
|
||||||
|
|
||||||
# With analyzed parent
|
# Collect the images with analyzed parents.
|
||||||
candidates = (Image
|
candidates = (Image
|
||||||
.select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum, Parent.docker_image_id.alias('parent_docker_image_id'), ParentImageStorage.uuid.alias('parent_storage_uuid'))
|
.select(Image.id,
|
||||||
|
Image.docker_image_id,
|
||||||
|
ImageStorage.uuid,
|
||||||
|
Parent.docker_image_id.alias('parent_docker_image_id'),
|
||||||
|
ParentImageStorage.uuid.alias('parent_storage_uuid'))
|
||||||
.join(Parent, on=(Image.parent_id == Parent.id))
|
.join(Parent, on=(Image.parent_id == Parent.id))
|
||||||
.join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage))
|
.join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage))
|
||||||
.switch(Image)
|
.switch(Image)
|
||||||
.join(ImageStorage)
|
.join(ImageStorage)
|
||||||
.where(Image.security_indexed_engine < version, Parent.security_indexed == True, Parent.security_indexed_engine >= version, ImageStorage.uploading == False, ImageStorage.checksum != '')
|
.where(Image.security_indexed_engine < version,
|
||||||
|
Parent.security_indexed == True,
|
||||||
|
Parent.security_indexed_engine >= version,
|
||||||
|
ImageStorage.uploading == False)
|
||||||
.limit(BATCH_SIZE*10)
|
.limit(BATCH_SIZE*10)
|
||||||
.alias('candidates'))
|
.alias('candidates'))
|
||||||
|
|
||||||
images = (Image
|
images = (Image
|
||||||
.select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum, candidates.c.parent_docker_image_id, candidates.c.parent_storage_uuid)
|
.select(candidates.c.id,
|
||||||
|
candidates.c.docker_image_id,
|
||||||
|
candidates.c.uuid,
|
||||||
|
candidates.c.parent_docker_image_id,
|
||||||
|
candidates.c.parent_storage_uuid)
|
||||||
.from_(candidates)
|
.from_(candidates)
|
||||||
.order_by(db_random_func())
|
.order_by(db_random_func())
|
||||||
.tuples()
|
.tuples()
|
||||||
.limit(BATCH_SIZE))
|
.limit(BATCH_SIZE))
|
||||||
|
|
||||||
for image in images:
|
for image in images:
|
||||||
rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': image[3], 'parent_storage_uuid': image[4]})
|
rimages.append({'image_id': image[0],
|
||||||
|
'docker_image_id': image[1],
|
||||||
|
'storage_uuid': image[2],
|
||||||
|
'parent_docker_image_id': image[3],
|
||||||
|
'parent_storage_uuid': image[4]})
|
||||||
|
|
||||||
# Re-shuffle, otherwise the images without parents will always be on the top
|
# Shuffle the images, otherwise the images without parents will always be on the top
|
||||||
random.shuffle(rimages)
|
random.shuffle(rimages)
|
||||||
|
|
||||||
return rimages
|
return rimages
|
||||||
|
|
||||||
def _get_storage_locations(uuid):
|
def _get_storage_locations(uuid):
|
||||||
|
@ -75,143 +101,212 @@ def _get_storage_locations(uuid):
|
||||||
.join(ImageStorage, JOIN_LEFT_OUTER)
|
.join(ImageStorage, JOIN_LEFT_OUTER)
|
||||||
.where(ImageStorage.uuid == uuid))
|
.where(ImageStorage.uuid == uuid))
|
||||||
|
|
||||||
locations = list()
|
return [location.location.name for location in query]
|
||||||
for location in query:
|
|
||||||
locations.append(location.location.name)
|
|
||||||
|
|
||||||
return locations
|
|
||||||
|
|
||||||
def _update_image(image, indexed, version):
|
def _update_image(image, indexed, version):
|
||||||
query = (Image
|
query = (Image
|
||||||
.select()
|
.select()
|
||||||
.join(ImageStorage)
|
.join(ImageStorage)
|
||||||
.where(Image.docker_image_id == image['docker_image_id'], ImageStorage.uuid == image['storage_uuid']))
|
.where(Image.docker_image_id == image['docker_image_id'],
|
||||||
|
ImageStorage.uuid == image['storage_uuid']))
|
||||||
|
|
||||||
updated_images = list()
|
ids_to_update = [row.id for row in query]
|
||||||
for image in query:
|
if not ids_to_update:
|
||||||
updated_images.append(image.id)
|
return
|
||||||
|
|
||||||
query = (Image
|
(Image
|
||||||
.update(security_indexed=indexed, security_indexed_engine=version)
|
.update(security_indexed=indexed, security_indexed_engine=version)
|
||||||
.where(Image.id << updated_images))
|
.where(Image.id << ids_to_update)
|
||||||
query.execute()
|
.execute())
|
||||||
|
|
||||||
|
|
||||||
class SecurityWorker(Worker):
|
class SecurityWorker(Worker):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(SecurityWorker, self).__init__()
|
super(SecurityWorker, self).__init__()
|
||||||
if self._load_configuration():
|
validator = SecurityConfigValidator(app, config_provider)
|
||||||
self.add_operation(self._index_images, INDEXING_INTERVAL)
|
if validator.valid():
|
||||||
|
secscan_config = app.config.get('SECURITY_SCANNER')
|
||||||
def _load_configuration(self):
|
self._api = secscan_config['ENDPOINT']
|
||||||
# Load configuration
|
self._target_version = secscan_config['ENGINE_VERSION_TARGET']
|
||||||
config = app.config.get('SECURITY_SCANNER')
|
|
||||||
|
|
||||||
if not config or not 'ENDPOINT' in config or not 'ENGINE_VERSION_TARGET' in config or not 'DISTRIBUTED_STORAGE_PREFERENCE' in app.config:
|
|
||||||
logger.exception('No configuration found for the security worker')
|
|
||||||
return False
|
|
||||||
self._api = config['ENDPOINT']
|
|
||||||
self._target_version = config['ENGINE_VERSION_TARGET']
|
|
||||||
self._default_storage_locations = app.config['DISTRIBUTED_STORAGE_PREFERENCE']
|
self._default_storage_locations = app.config['DISTRIBUTED_STORAGE_PREFERENCE']
|
||||||
|
self._cert = validator.cert()
|
||||||
|
self._keys = validator.keypair()
|
||||||
|
|
||||||
self._ca_verification = False
|
self.add_operation(self._index_images, INDEXING_INTERVAL)
|
||||||
self._cert = None
|
logger.warning('Failed to validate security scan configuration')
|
||||||
if 'CA_CERTIFICATE_FILENAME' in config:
|
|
||||||
self._ca_verification = os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['CA_CERTIFICATE_FILENAME'])
|
|
||||||
if not os.path.isfile(self._ca_verification):
|
|
||||||
logger.exception('Could not find configured CA file')
|
|
||||||
return False
|
|
||||||
if 'PRIVATE_KEY_FILENAME' in config and 'PUBLIC_KEY_FILENAME' in config:
|
|
||||||
self._cert = (
|
|
||||||
os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['PUBLIC_KEY_FILENAME']),
|
|
||||||
os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['PRIVATE_KEY_FILENAME']),
|
|
||||||
)
|
|
||||||
if not os.path.isfile(self._cert[0]) or not os.path.isfile(self._cert[1]):
|
|
||||||
logger.exception('Could not find configured key pair files')
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
def _get_image_url(self, image):
|
||||||
|
""" Gets the download URL for an image and if the storage doesn't exist,
|
||||||
def _index_images(self):
|
marks the image as unindexed. """
|
||||||
with UseThenDisconnect(app.config):
|
path = storage.image_layer_path(image['storage_uuid'])
|
||||||
while True:
|
|
||||||
# Get images to analyze
|
|
||||||
try:
|
|
||||||
images = _get_image_to_export(self._target_version)
|
|
||||||
except Image.DoesNotExist:
|
|
||||||
logger.debug('No more image to analyze')
|
|
||||||
return
|
|
||||||
|
|
||||||
for img in images:
|
|
||||||
# Get layer storage URL
|
|
||||||
path = storage.image_layer_path(img['storage_uuid'])
|
|
||||||
locations = self._default_storage_locations
|
locations = self._default_storage_locations
|
||||||
if not storage.exists(locations, path):
|
|
||||||
locations = _get_storage_locations(img['storage_uuid'])
|
|
||||||
if not storage.exists(locations, path):
|
|
||||||
logger.warning('Could not find a valid location to download layer %s', img['docker_image_id']+'.'+img['storage_uuid'])
|
|
||||||
# Mark as analyzed because that error is most likely to occur during the pre-process, with the database copy
|
|
||||||
# when images are actually removed on the real database (and therefore in S3)
|
|
||||||
_update_image(img, False, self._target_version)
|
|
||||||
continue
|
|
||||||
uri = storage.get_direct_download_url(locations, path)
|
|
||||||
if uri == None:
|
|
||||||
# Local storage hack
|
|
||||||
uri = path
|
|
||||||
|
|
||||||
# Forge request
|
if not storage.exists(locations, path):
|
||||||
|
locations = _get_storage_locations(image['storage_uuid'])
|
||||||
|
|
||||||
|
if not locations or not storage.exists(locations, path):
|
||||||
|
logger.warning('Could not find a valid location to download layer %s',
|
||||||
|
image['docker_image_id']+'.'+image['storage_uuid'])
|
||||||
|
_update_image(image, False, self._target_version)
|
||||||
|
return None
|
||||||
|
|
||||||
|
uri = storage.get_direct_download_url(locations, path)
|
||||||
|
if uri is None:
|
||||||
|
# Handle local storage
|
||||||
|
local_storage_enabled = False
|
||||||
|
for storage_type, _ in app.config.get('DISTRIBUTED_STORAGE_CONFIG', {}).values():
|
||||||
|
if storage_type == 'LocalStorage':
|
||||||
|
local_storage_enabled = True
|
||||||
|
|
||||||
|
if local_storage_enabled:
|
||||||
|
uri = path
|
||||||
|
else:
|
||||||
|
logger.warning('Could not get image URL and local storage was not enabled')
|
||||||
|
return None
|
||||||
|
|
||||||
|
return uri
|
||||||
|
|
||||||
|
def _new_request(self, image):
|
||||||
|
url = self._get_image_url(image)
|
||||||
|
if url is None:
|
||||||
|
return None
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
'ID': img['docker_image_id']+'.'+img['storage_uuid'],
|
'ID': '%s.%s' % (image['docker_image_id'], image['storage_uuid']),
|
||||||
'TarSum': img['storage_checksum'],
|
'Path': url,
|
||||||
'Path': uri
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None:
|
if image['parent_docker_image_id'] is not None and image['parent_storage_uuid'] is not None:
|
||||||
request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid']
|
request['ParentID'] = '%s.%s' % (image['parent_docker_image_id'],
|
||||||
|
image['parent_storage_uuid'])
|
||||||
|
|
||||||
# Post request
|
return request
|
||||||
|
|
||||||
|
def _analyze_image(self, image):
|
||||||
|
""" Analyzes an image by passing it to Clair. Returns the vulnerabilities detected
|
||||||
|
(if any) or None on error.
|
||||||
|
"""
|
||||||
|
request = self._new_request(image)
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Analyze the image.
|
||||||
try:
|
try:
|
||||||
logger.info('Analyzing %s', request['ID'])
|
logger.info('Analyzing %s', request['ID'])
|
||||||
# Using invalid certificates doesn't return proper errors because of
|
# Using invalid certificates doesn't return proper errors because of
|
||||||
# https://github.com/shazow/urllib3/issues/556
|
# https://github.com/shazow/urllib3/issues/556
|
||||||
httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, cert=self._cert, verify=self._ca_verification)
|
httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request,
|
||||||
except:
|
cert=self._keys, verify=self._cert)
|
||||||
logger.exception('An exception occurred when analyzing layer ID %s : %s', request['ID'], exc_info()[0])
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
jsonResponse = httpResponse.json()
|
jsonResponse = httpResponse.json()
|
||||||
except:
|
except (requests.exceptions.RequestException, ValueError):
|
||||||
logger.exception('An exception occurred when analyzing layer ID %s : the response is not valid JSON (%s)', request['ID'], httpResponse.text)
|
logger.exception('An exception occurred when analyzing layer ID %s', request['ID'])
|
||||||
return
|
return None
|
||||||
|
|
||||||
if httpResponse.status_code == 201:
|
# Handle any errors from the security scanner.
|
||||||
# The layer has been successfully indexed
|
if httpResponse.status_code != 201:
|
||||||
|
if 'OS and/or package manager are not supported' in jsonResponse.get('Message', ''):
|
||||||
|
# The current engine could not index this layer
|
||||||
|
logger.warning('A warning event occurred when analyzing layer ID %s : %s',
|
||||||
|
request['ID'], jsonResponse['Message'])
|
||||||
|
|
||||||
|
# Hopefully, there is no version lower than the target one running
|
||||||
|
_update_image(image, False, self._target_version)
|
||||||
|
else:
|
||||||
|
logger.warning('Got non-201 when analyzing layer ID %s: %s', request['ID'], jsonResponse)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify that the version matches.
|
||||||
api_version = jsonResponse['Version']
|
api_version = jsonResponse['Version']
|
||||||
if api_version < self._target_version:
|
if api_version < self._target_version:
|
||||||
logger.warning('An engine runs on version %d but the target version is %d')
|
logger.warning('An engine runs on version %d but the target version is %d')
|
||||||
_update_image(img, True, api_version)
|
|
||||||
logger.info('Layer ID %s : analyzed successfully', request['ID'])
|
# Mark the image as analyzed.
|
||||||
else:
|
logger.debug('Layer %s analyzed successfully; Loading vulnerabilities for layer',
|
||||||
if 'Message' in jsonResponse:
|
image['image_id'])
|
||||||
if 'OS and/or package manager are not supported' in jsonResponse['Message']:
|
_update_image(image, True, api_version)
|
||||||
# The current engine could not index this layer
|
|
||||||
logger.warning('A warning event occurred when analyzing layer ID %s : %s', request['ID'], jsonResponse['Message'])
|
# Lookup the vulnerabilities for the image, now that it is analyzed.
|
||||||
# Hopefully, there is no version lower than the target one running
|
try:
|
||||||
_update_image(img, False, self._target_version)
|
response = secscan_api.call('layers/%s/vulnerabilities', None, request['ID'])
|
||||||
else:
|
logger.debug('Got response %s for vulnerabilities for layer %s',
|
||||||
logger.exception('An exception occurred when analyzing layer ID %s : %d %s', request['ID'], httpResponse.status_code, jsonResponse['Message'])
|
response.status_code, image['image_id'])
|
||||||
return
|
if response.status_code == 404:
|
||||||
else:
|
return None
|
||||||
logger.exception('An exception occurred when analyzing layer ID %s : %d', request['ID'], httpResponse.status_code)
|
except (requests.exceptions.RequestException, ValueError):
|
||||||
|
logger.exception('Failed to get vulnerability response for %s', image['image_id'])
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _index_images(self):
|
||||||
|
logger.debug('Started indexing')
|
||||||
|
|
||||||
|
with UseThenDisconnect(app.config):
|
||||||
|
while True:
|
||||||
|
# Lookup the images to index.
|
||||||
|
images = []
|
||||||
|
try:
|
||||||
|
logger.debug('Looking up images to index')
|
||||||
|
images = _get_images_to_export_list(self._target_version)
|
||||||
|
except Image.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
logger.debug('No more images left to analyze')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.debug('Found %d images to index', len(images))
|
||||||
|
for image in images:
|
||||||
|
# Analyze the image, retrieving the vulnerabilities (if any).
|
||||||
|
sec_data = self._analyze_image(image)
|
||||||
|
if sec_data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not sec_data.get('Vulnerabilities'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Dispatch events for any detected vulnerabilities
|
||||||
|
logger.debug('Got vulnerabilities for layer %s: %s', image['image_id'], sec_data)
|
||||||
|
event = ExternalNotificationEvent.get(name='vulnerability_found')
|
||||||
|
matching = (RepositoryTag
|
||||||
|
.select(RepositoryTag, Repository)
|
||||||
|
.distinct()
|
||||||
|
.join(Repository)
|
||||||
|
.join(RepositoryNotification)
|
||||||
|
.where(RepositoryNotification.event == event,
|
||||||
|
RepositoryTag.image == image['image_id'],
|
||||||
|
RepositoryTag.hidden == False,
|
||||||
|
RepositoryTag.lifetime_end_ts >> None))
|
||||||
|
|
||||||
|
repository_map = defaultdict(list)
|
||||||
|
|
||||||
|
for tag in matching:
|
||||||
|
repository_map[tag.repository_id].append(tag)
|
||||||
|
|
||||||
|
for repository_id in repository_map:
|
||||||
|
tags = repository_map[repository_id]
|
||||||
|
|
||||||
|
for vuln in sec_data['Vulnerabilities']:
|
||||||
|
event_data = {
|
||||||
|
'tags': [tag.name for tag in tags],
|
||||||
|
'vulnerability': {
|
||||||
|
'id': vuln['ID'],
|
||||||
|
'description': vuln['Description'],
|
||||||
|
'link': vuln['Link'],
|
||||||
|
'priority': vuln['Priority'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_notification(tags[0].repository, 'vulnerability_found', event_data)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
|
||||||
logging.getLogger('apscheduler').setLevel(logging.CRITICAL)
|
|
||||||
|
|
||||||
if not features.SECURITY_SCANNER:
|
if not features.SECURITY_SCANNER:
|
||||||
logger.debug('Security scanner disabled; skipping')
|
logger.debug('Security scanner disabled; skipping SecurityWorker')
|
||||||
while True:
|
while True:
|
||||||
time.sleep(100000)
|
time.sleep(100000)
|
||||||
|
|
||||||
|
logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False)
|
||||||
worker = SecurityWorker()
|
worker = SecurityWorker()
|
||||||
worker.start()
|
worker.start()
|
||||||
|
|
|
@ -61,7 +61,7 @@ class Worker(object):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)
|
logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False)
|
||||||
|
|
||||||
if not app.config.get('SETUP_COMPLETE', False):
|
if not app.config.get('SETUP_COMPLETE', False):
|
||||||
logger.info('Product setup is not yet complete; skipping worker startup')
|
logger.info('Product setup is not yet complete; skipping worker startup')
|
||||||
|
|
Reference in a new issue