From c3c13de63bc8a3f29f1bfd3f1891e68954d714cc Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 4 Nov 2015 16:20:45 -0500 Subject: [PATCH 01/17] Remove the used_legacy_github column --- data/database.py | 3 -- ...73669db7e12_remove_legacy_github_column.py | 25 ++++++++++++++++ util/migrate/migrategithubdeploykeys.py | 29 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 data/migrations/versions/73669db7e12_remove_legacy_github_column.py diff --git a/data/database.py b/data/database.py index cc0beafa6..d540ec013 100644 --- a/data/database.py +++ b/data/database.py @@ -472,9 +472,6 @@ class RepositoryBuildTrigger(BaseModel): pull_robot = QuayUserField(allows_robots=True, null=True, related_name='triggerpullrobot', robot_null_delete=True) - # TODO(jschorr): Remove this column once we verify the backfill has succeeded. - used_legacy_github = BooleanField(null=True, default=False) - class EmailConfirmation(BaseModel): code = CharField(default=random_string_generator(), unique=True, index=True) diff --git a/data/migrations/versions/73669db7e12_remove_legacy_github_column.py b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py new file mode 100644 index 000000000..38698c5eb --- /dev/null +++ b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py @@ -0,0 +1,25 @@ +"""Remove legacy github column + +Revision ID: 73669db7e12 +Revises: 35f538da62 +Create Date: 2015-11-04 16:18:18.107314 + +""" + +# revision identifiers, used by Alembic. +revision = '73669db7e12' +down_revision = '35f538da62' + +from alembic import op +import sqlalchemy as sa + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('repositorybuildtrigger', 'used_legacy_github') + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('repositorybuildtrigger', sa.Column('used_legacy_github', sa.Boolean(), nullable=True)) + ### end Alembic commands ### diff --git a/util/migrate/migrategithubdeploykeys.py b/util/migrate/migrategithubdeploykeys.py index 5e2f4089e..b2c1903a1 100644 --- a/util/migrate/migrategithubdeploykeys.py +++ b/util/migrate/migrategithubdeploykeys.py @@ -2,7 +2,8 @@ import logging import logging.config import json -from data.database import RepositoryBuildTrigger, BuildTriggerService, db, db_for_update +from data.database import (db, db_for_update, BaseModel, CharField, ForeignKeyField, + TextField, BooleanField) from app import app from buildtrigger.basehandler import BuildTriggerHandler from util.security.ssh import generate_ssh_keypair @@ -10,6 +11,32 @@ from github import GithubException logger = logging.getLogger(__name__) +class BuildTriggerService(BaseModel): + name = CharField(index=True, unique=True) + +class Repository(BaseModel): + pass + +class User(BaseModel): + pass + +class AccessToken(BaseModel): + pass + +class RepositoryBuildTrigger(BaseModel): + uuid = CharField() + service = ForeignKeyField(BuildTriggerService, index=True) + repository = ForeignKeyField(Repository, index=True) + connected_user = ForeignKeyField(User) + auth_token = CharField(null=True) + private_key = TextField(null=True) + config = TextField(default='{}') + write_token = ForeignKeyField(AccessToken, null=True) + pull_robot = ForeignKeyField(User, null=True, related_name='triggerpullrobot') + + used_legacy_github = BooleanField(null=True, default=False) + + def backfill_github_deploykeys(): """ Generates and saves private deploy keys for any GitHub build triggers still relying on the old buildpack behavior. """ From bbf4a1fac4ce868a04fe4ea34fc14e297cbae376 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 4 Nov 2015 16:20:45 -0500 Subject: [PATCH 02/17] Remove the used_legacy_github column --- data/database.py | 3 -- ...73669db7e12_remove_legacy_github_column.py | 25 ++++++++++++++++ util/migrate/migrategithubdeploykeys.py | 29 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 data/migrations/versions/73669db7e12_remove_legacy_github_column.py diff --git a/data/database.py b/data/database.py index cc0beafa6..d540ec013 100644 --- a/data/database.py +++ b/data/database.py @@ -472,9 +472,6 @@ class RepositoryBuildTrigger(BaseModel): pull_robot = QuayUserField(allows_robots=True, null=True, related_name='triggerpullrobot', robot_null_delete=True) - # TODO(jschorr): Remove this column once we verify the backfill has succeeded. - used_legacy_github = BooleanField(null=True, default=False) - class EmailConfirmation(BaseModel): code = CharField(default=random_string_generator(), unique=True, index=True) diff --git a/data/migrations/versions/73669db7e12_remove_legacy_github_column.py b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py new file mode 100644 index 000000000..38698c5eb --- /dev/null +++ b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py @@ -0,0 +1,25 @@ +"""Remove legacy github column + +Revision ID: 73669db7e12 +Revises: 35f538da62 +Create Date: 2015-11-04 16:18:18.107314 + +""" + +# revision identifiers, used by Alembic. +revision = '73669db7e12' +down_revision = '35f538da62' + +from alembic import op +import sqlalchemy as sa + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('repositorybuildtrigger', 'used_legacy_github') + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('repositorybuildtrigger', sa.Column('used_legacy_github', sa.Boolean(), nullable=True)) + ### end Alembic commands ### diff --git a/util/migrate/migrategithubdeploykeys.py b/util/migrate/migrategithubdeploykeys.py index 5e2f4089e..b2c1903a1 100644 --- a/util/migrate/migrategithubdeploykeys.py +++ b/util/migrate/migrategithubdeploykeys.py @@ -2,7 +2,8 @@ import logging import logging.config import json -from data.database import RepositoryBuildTrigger, BuildTriggerService, db, db_for_update +from data.database import (db, db_for_update, BaseModel, CharField, ForeignKeyField, + TextField, BooleanField) from app import app from buildtrigger.basehandler import BuildTriggerHandler from util.security.ssh import generate_ssh_keypair @@ -10,6 +11,32 @@ from github import GithubException logger = logging.getLogger(__name__) +class BuildTriggerService(BaseModel): + name = CharField(index=True, unique=True) + +class Repository(BaseModel): + pass + +class User(BaseModel): + pass + +class AccessToken(BaseModel): + pass + +class RepositoryBuildTrigger(BaseModel): + uuid = CharField() + service = ForeignKeyField(BuildTriggerService, index=True) + repository = ForeignKeyField(Repository, index=True) + connected_user = ForeignKeyField(User) + auth_token = CharField(null=True) + private_key = TextField(null=True) + config = TextField(default='{}') + write_token = ForeignKeyField(AccessToken, null=True) + pull_robot = ForeignKeyField(User, null=True, related_name='triggerpullrobot') + + used_legacy_github = BooleanField(null=True, default=False) + + def backfill_github_deploykeys(): """ Generates and saves private deploy keys for any GitHub build triggers still relying on the old buildpack behavior. """ From 2b3633b107b7875a5fb8b639c7d24aca7466145b Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 4 Nov 2015 16:20:45 -0500 Subject: [PATCH 03/17] Remove the used_legacy_github column --- .../versions/73669db7e12_remove_legacy_github_column.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/data/migrations/versions/73669db7e12_remove_legacy_github_column.py b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py index 38698c5eb..e6ee5d040 100644 --- a/data/migrations/versions/73669db7e12_remove_legacy_github_column.py +++ b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py @@ -12,6 +12,10 @@ down_revision = '35f538da62' from alembic import op import sqlalchemy as sa +<<<<<<< HEAD +======= +from sqlalchemy.dialects import mysql +>>>>>>> Remove the used_legacy_github column def upgrade(tables): ### commands auto generated by Alembic - please adjust! ### From 3d0bcbaaeb8b93a309bd40605718e132ed9d0730 Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Wed, 4 Nov 2015 16:18:53 -0500 Subject: [PATCH 04/17] Move v1 checksums to image and track v2 separately --- data/database.py | 4 ++- ...27d36939e4_separate_v1_and_v2_checksums.py | 30 +++++++++++++++++++ ...73669db7e12_remove_legacy_github_column.py | 4 --- data/model/blob.py | 6 ++-- data/model/image.py | 8 +++-- digest/checksums.py | 8 +++++ endpoints/v1/registry.py | 20 +++++++++---- initdb.py | 2 +- 8 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 data/migrations/versions/2827d36939e4_separate_v1_and_v2_checksums.py diff --git a/data/database.py b/data/database.py index d540ec013..1e50b15d4 100644 --- a/data/database.py +++ b/data/database.py @@ -484,11 +484,12 @@ class EmailConfirmation(BaseModel): class ImageStorage(BaseModel): uuid = CharField(default=uuid_generator, index=True, unique=True) - checksum = CharField(null=True) + checksum = CharField(null=True) # TODO remove when all checksums have been moved back to Image image_size = BigIntegerField(null=True) uncompressed_size = BigIntegerField(null=True) uploading = BooleanField(default=True, null=True) cas_path = BooleanField(default=True) + content_checksum = CharField(null=True, index=True) class ImageStorageTransformation(BaseModel): @@ -570,6 +571,7 @@ class Image(BaseModel): command = TextField(null=True) aggregate_size = BigIntegerField(null=True) v1_json_metadata = TextField(null=True) + v1_checksum = CharField(null=True) class Meta: database = db diff --git a/data/migrations/versions/2827d36939e4_separate_v1_and_v2_checksums.py b/data/migrations/versions/2827d36939e4_separate_v1_and_v2_checksums.py new file mode 100644 index 000000000..4e161daed --- /dev/null +++ b/data/migrations/versions/2827d36939e4_separate_v1_and_v2_checksums.py @@ -0,0 +1,30 @@ +"""Separate v1 and v2 checksums. + +Revision ID: 2827d36939e4 +Revises: 73669db7e12 +Create Date: 2015-11-04 16:29:48.905775 + +""" + +# revision identifiers, used by Alembic. +revision = '2827d36939e4' +down_revision = '73669db7e12' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('image', sa.Column('v1_checksum', sa.String(length=255), nullable=True)) + op.add_column('imagestorage', sa.Column('content_checksum', sa.String(length=255), nullable=True)) + op.create_index('imagestorage_content_checksum', 'imagestorage', ['content_checksum'], unique=False) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index('imagestorage_content_checksum', table_name='imagestorage') + op.drop_column('imagestorage', 'content_checksum') + op.drop_column('image', 'v1_checksum') + ### end Alembic commands ### diff --git a/data/migrations/versions/73669db7e12_remove_legacy_github_column.py b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py index e6ee5d040..38698c5eb 100644 --- a/data/migrations/versions/73669db7e12_remove_legacy_github_column.py +++ b/data/migrations/versions/73669db7e12_remove_legacy_github_column.py @@ -12,10 +12,6 @@ down_revision = '35f538da62' from alembic import op import sqlalchemy as sa -<<<<<<< HEAD -======= -from sqlalchemy.dialects import mysql ->>>>>>> Remove the used_legacy_github column def upgrade(tables): ### commands auto generated by Alembic - please adjust! ### diff --git a/data/model/blob.py b/data/model/blob.py index 4bad62584..5547c7646 100644 --- a/data/model/blob.py +++ b/data/model/blob.py @@ -17,7 +17,7 @@ def get_repo_blob_by_digest(namespace, repo_name, blob_digest): .join(Repository) .join(Namespace) .where(Repository.name == repo_name, Namespace.username == namespace, - ImageStorage.checksum == blob_digest)) + ImageStorage.content_checksum == blob_digest)) if not placements: raise BlobDoesNotExist('Blob does not exist with digest: {0}'.format(blob_digest)) @@ -35,11 +35,11 @@ def store_blob_record_and_temp_link(namespace, repo_name, blob_digest, location_ repo = _basequery.get_existing_repository(namespace, repo_name) try: - storage = ImageStorage.get(checksum=blob_digest) + storage = ImageStorage.get(content_checksum=blob_digest) location = ImageStorageLocation.get(name=location_name) ImageStoragePlacement.get(storage=storage, location=location) except ImageStorage.DoesNotExist: - storage = ImageStorage.create(checksum=blob_digest) + storage = ImageStorage.create(content_checksum=blob_digest) except ImageStoragePlacement.DoesNotExist: ImageStoragePlacement.create(storage=storage, location=location) diff --git a/data/model/image.py b/data/model/image.py index 078875417..96b01c8e6 100644 --- a/data/model/image.py +++ b/data/model/image.py @@ -284,10 +284,7 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name, created except Image.DoesNotExist: raise DataModelException('No image with specified id and repository') - # We cleanup any old checksum in case it's a retry after a fail - fetched.storage.checksum = None fetched.created = datetime.now() - if created_date_str is not None: try: fetched.created = dateutil.parser.parse(created_date_str).replace(tzinfo=None) @@ -295,6 +292,11 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name, created # parse raises different exceptions, so we cannot use a specific kind of handler here. pass + # We cleanup any old checksum in case it's a retry after a fail + fetched.v1_checksum = None + fetched.storage.checksum = None # TODO remove when storage checksums are no longer read + fetched.storage.content_checksum = None + fetched.comment = comment fetched.command = command fetched.v1_json_metadata = v1_json_metadata diff --git a/digest/checksums.py b/digest/checksums.py index ea30e4dc1..95a39ce96 100644 --- a/digest/checksums.py +++ b/digest/checksums.py @@ -75,6 +75,14 @@ def simple_checksum_handler(json_data): return h, fn +def content_checksum_handler(): + h = hashlib.sha256() + + def fn(buf): + h.update(buf) + return h, fn + + def compute_simple(fp, json_data): data = json_data + '\n' return 'sha256:{0}'.format(sha256_file(fp, data)) diff --git a/endpoints/v1/registry.py b/endpoints/v1/registry.py index 3d049c757..19915363c 100644 --- a/endpoints/v1/registry.py +++ b/endpoints/v1/registry.py @@ -249,6 +249,10 @@ def put_image_layer(namespace, repository, image_id): h, sum_hndlr = checksums.simple_checksum_handler(json_data) sr.add_handler(sum_hndlr) + # Add a handler which computes the content checksum only + ch, content_sum_hndlr = checksums.content_checksum_handler() + sr.add_handler(content_sum_hndlr) + # Stream write the data to storage. with database.CloseForLongOperation(app.config): try: @@ -278,6 +282,7 @@ def put_image_layer(namespace, repository, image_id): # We don't have a checksum stored yet, that's fine skipping the check. # Not removing the mark though, image is not downloadable yet. session['checksum'] = csums + session['content_checksum'] = 'sha256:{0}'.format(ch.hexdigest()) return make_response('true', 200) checksum = repo_image.storage.checksum @@ -339,8 +344,9 @@ def put_image_checksum(namespace, repository, image_id): abort(409, 'Cannot set checksum for image %(image_id)s', issue='image-write-error', image_id=image_id) - logger.debug('Storing image checksum') - err = store_checksum(repo_image.storage, checksum) + logger.debug('Storing image and content checksums') + content_checksum = session.get('content_checksum', None) + err = store_checksum(repo_image, checksum, content_checksum) if err: abort(400, err) @@ -429,14 +435,18 @@ def generate_ancestry(image_id, uuid, locations, parent_id=None, parent_uuid=Non store.put_content(locations, store.image_ancestry_path(uuid), json.dumps(data)) -def store_checksum(image_storage, checksum): +def store_checksum(image_with_storage, checksum, content_checksum): checksum_parts = checksum.split(':') if len(checksum_parts) != 2: return 'Invalid checksum format' # We store the checksum - image_storage.checksum = checksum - image_storage.save() + image_with_storage.storage.checksum = checksum # TODO remove when v1 checksums are on image only + image_with_storage.storage.content_checksum = content_checksum + image_with_storage.storage.save() + + image_with_storage.v1_checksum = checksum + image_with_storage.save() @v1_bp.route('/images//json', methods=['PUT']) diff --git a/initdb.py b/initdb.py index 33b8e2b5a..7d2218778 100644 --- a/initdb.py +++ b/initdb.py @@ -82,7 +82,7 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): new_image_locations = new_image.storage.locations new_image.storage.uuid = __gen_image_uuid(repo, image_num) new_image.storage.uploading = False - new_image.storage.checksum = checksum + new_image.storage.content_checksum = checksum new_image.storage.save() # Write some data for the storage. From f59e35cc812e2218cff6cf36ff3f6043158d3094 Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Mon, 5 Oct 2015 13:35:01 -0400 Subject: [PATCH 05/17] Add support for Quay's vulnerability tool --- conf/init/service/securityworker/log/run | 2 + conf/init/service/securityworker/run | 8 + data/database.py | 6 + ...c20cc_backfill_parent_ids_and_checksums.py | 21 ++ ...add_support_for_quay_s_security_indexer.py | 32 +++ data/model/image.py | 1 + initdb.py | 5 + test/data/test.db | Bin 831488 -> 376832 bytes util/migrate/backfill_checksums.py | 67 ++++++ util/migrate/backfill_parent_id.py | 49 ++++ workers/securityworker.py | 218 ++++++++++++++++++ 11 files changed, 409 insertions(+) create mode 100644 conf/init/service/securityworker/log/run create mode 100644 conf/init/service/securityworker/run create mode 100644 data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py create mode 100644 data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py create mode 100644 util/migrate/backfill_checksums.py create mode 100644 util/migrate/backfill_parent_id.py create mode 100644 workers/securityworker.py diff --git a/conf/init/service/securityworker/log/run b/conf/init/service/securityworker/log/run new file mode 100644 index 000000000..8de3dfdec --- /dev/null +++ b/conf/init/service/securityworker/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t securityworker diff --git a/conf/init/service/securityworker/run b/conf/init/service/securityworker/run new file mode 100644 index 000000000..c40f9aa4b --- /dev/null +++ b/conf/init/service/securityworker/run @@ -0,0 +1,8 @@ +#! /bin/bash + +echo 'Starting security scanner worker' + +cd / +venv/bin/python -m workers.securityworker 2>&1 + +echo 'Security scanner worker exited' diff --git a/data/database.py b/data/database.py index 1e50b15d4..305a34436 100644 --- a/data/database.py +++ b/data/database.py @@ -573,12 +573,18 @@ class Image(BaseModel): v1_json_metadata = TextField(null=True) v1_checksum = CharField(null=True) + security_indexed = BooleanField(default=False) + security_indexed_engine = IntegerField(default=-1) + parent = ForeignKeyField('self', index=True, null=True, related_name='children') + class Meta: database = db read_slaves = (read_slave,) indexes = ( # we don't really want duplicates (('repository', 'docker_image_id'), True), + + (('security_indexed_engine', 'security_indexed'), False), ) diff --git a/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py b/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py new file mode 100644 index 000000000..afd589809 --- /dev/null +++ b/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py @@ -0,0 +1,21 @@ +"""backfill parent ids and checksums +Revision ID: 2fb9492c20cc +Revises: 57dad559ff2d +Create Date: 2015-07-14 17:38:47.397963 +""" + +# revision identifiers, used by Alembic. +revision = '2fb9492c20cc' +down_revision = '57dad559ff2d' + +from alembic import op +import sqlalchemy as sa +from util.migrate.backfill_parent_id import backfill_parent_id +from util.migrate.backfill_checksums import backfill_checksums + +def upgrade(tables): + backfill_parent_id() + backfill_checksums() + +def downgrade(tables): + pass diff --git a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py new file mode 100644 index 000000000..7ba826b14 --- /dev/null +++ b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py @@ -0,0 +1,32 @@ +"""add support for quay's security indexer +Revision ID: 57dad559ff2d +Revises: 154f2befdfbe +Create Date: 2015-07-13 16:51:41.669249 +""" + +# revision identifiers, used by Alembic. +revision = '57dad559ff2d' +down_revision = '3ff4fbc94644' + +from alembic import op +import sqlalchemy as sa + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('image', sa.Column('parent_id', sa.Integer(), nullable=True)) + op.add_column('image', sa.Column('security_indexed', sa.Boolean(), nullable=False)) + op.add_column('image', sa.Column('security_indexed_engine', sa.Integer(), nullable=False)) + op.create_index('image_parent_id', 'image', ['parent_id'], unique=False) + op.create_foreign_key(op.f('fk_image_parent_id_image'), 'image', 'image', ['parent_id'], ['id']) + ### end Alembic commands ### + op.create_index('image_security_indexed_engine_security_indexed', 'image', ['security_indexed_engine', 'security_indexed']) + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('fk_image_parent_id_image'), 'image', type_='foreignkey') + op.drop_index('image_parent_id', table_name='image') + op.drop_column('image', 'security_indexed') + op.drop_column('image', 'security_indexed_engine') + op.drop_column('image', 'parent_id') + ### end Alembic commands ### + op.drop_index('image_security_indexed', 'image') diff --git a/data/model/image.py b/data/model/image.py index 96b01c8e6..7b673ee2f 100644 --- a/data/model/image.py +++ b/data/model/image.py @@ -303,6 +303,7 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name, created if parent: fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id) + fetched.parent = parent fetched.save() fetched.storage.save() diff --git a/initdb.py b/initdb.py index 7d2218778..357afd5e2 100644 --- a/initdb.py +++ b/initdb.py @@ -5,6 +5,7 @@ import random import calendar import os +from sys import maxsize from datetime import datetime, timedelta from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, savepoint_sqlite, savepoint) @@ -94,6 +95,10 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): for path_builder in paths: path = path_builder(new_image.storage.uuid) store.put_content('local_us', path, checksum) + + new_image.security_indexed = False + new_image.security_indexed_engine = maxsize + new_image.save() creation_time = REFERENCE_DATE + timedelta(weeks=image_num) + timedelta(days=model_num) command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)] diff --git a/test/data/test.db b/test/data/test.db index 5e5246bbaaf99199d204c2e60c712e38757bbaf6..6b318ccd35bcfe1ce60d788a341d160f7a773de7 100644 GIT binary patch delta 58355 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 Oae!W{+1c2C)&Bube70`@ delta 42110 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 K&yzRN-2VZvcM??q diff --git a/util/migrate/backfill_checksums.py b/util/migrate/backfill_checksums.py new file mode 100644 index 000000000..42c1fed44 --- /dev/null +++ b/util/migrate/backfill_checksums.py @@ -0,0 +1,67 @@ +import logging +from app import storage as store +from data.database import ImageStorage, ImageStoragePlacement, ImageStorageLocation, JOIN_LEFT_OUTER +from digest import checksums + +logger = logging.getLogger(__name__) + +def _get_imagestorages_with_locations(query_modifier): + query = (ImageStoragePlacement + .select(ImageStoragePlacement, ImageStorage, ImageStorageLocation) + .join(ImageStorageLocation) + .switch(ImageStoragePlacement) + .join(ImageStorage, JOIN_LEFT_OUTER)) + query = query_modifier(query) + + location_list = list(query) + + storages = {} + for location in location_list: + storage = location.storage + + if not storage.id in storages: + storages[storage.id] = storage + storage.locations = set() + else: + storage = storages[storage.id] + + storage.locations.add(location.location.name) + + return storages.values() + +def backfill_checksum(imagestorage_with_locations): + try: + json_data = store.get_content(imagestorage_with_locations.locations, store.image_json_path(imagestorage_with_locations.uuid)) + with store.stream_read_file(imagestorage_with_locations.locations, store.image_layer_path(imagestorage_with_locations.uuid)) as fp: + imagestorage_with_locations.checksum = 'sha256:{0}'.format(checksums.sha256_file(fp, json_data + '\n')) + imagestorage_with_locations.save() + except IOError as e: + if str(e).startswith("No such key"): + imagestorage_with_locations.checksum = 'unknown:{0}'.format(imagestorage_with_locations.uuid) + imagestorage_with_locations.save() + except: + logger.exception('exception when backfilling checksum of %s', imagestorage_with_locations.uuid) + +def backfill_checksums(): + logger.setLevel(logging.DEBUG) + + logger.debug('backfill_checksums: Starting') + logger.debug('backfill_checksums: This can be a LONG RUNNING OPERATION. Please wait!') + + def limit_to_empty_checksum(query): + return query.where(ImageStorage.checksum >> None, ImageStorage.uploading == False).limit(100) + + while True: + storages = _get_imagestorages_with_locations(limit_to_empty_checksum) + if len(storages) == 0: + logger.debug('backfill_checksums: Completed') + return + + for storage in storages: + backfill_checksum(storage) + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + logging.getLogger('peewee').setLevel(logging.CRITICAL) + logging.getLogger('boto').setLevel(logging.CRITICAL) + backfill_checksums() diff --git a/util/migrate/backfill_parent_id.py b/util/migrate/backfill_parent_id.py new file mode 100644 index 000000000..0d2540489 --- /dev/null +++ b/util/migrate/backfill_parent_id.py @@ -0,0 +1,49 @@ +import logging +from data.database import Image, ImageStorage, db +from app import app + +logger = logging.getLogger(__name__) + +def backfill_parent_id(): + logger.setLevel(logging.DEBUG) + + logger.debug('backfill_parent_id: Starting') + logger.debug('backfill_parent_id: This can be a LONG RUNNING OPERATION. Please wait!') + + # Check for any images without parent + has_images = bool(list(Image + .select(Image.id) + .join(ImageStorage) + .where(Image.parent >> None, Image.ancestors != '/', ImageStorage.uploading == False) + .limit(1))) + + if not has_images: + logger.debug('backfill_parent_id: No migration needed') + return + + while True: + # Load the record from the DB. + batch_images_ids = list(Image + .select(Image.id) + .join(ImageStorage) + .where(Image.parent >> None, Image.ancestors != '/', ImageStorage.uploading == False) + .limit(100)) + + if len(batch_images_ids) == 0: + logger.debug('backfill_parent_id: Completed') + return + + for image_id in batch_images_ids: + with app.config['DB_TRANSACTION_FACTORY'](db): + try: + image = Image.select(Image.id, Image.ancestors).where(Image.id == image_id).get() + image.parent = image.ancestors.split('/')[-2] + image.save() + except Image.DoesNotExist: + pass + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + logging.getLogger('peewee').setLevel(logging.CRITICAL) + + backfill_parent_id() diff --git a/workers/securityworker.py b/workers/securityworker.py new file mode 100644 index 000000000..29fdd3dcf --- /dev/null +++ b/workers/securityworker.py @@ -0,0 +1,218 @@ +import logging +import requests +import features +import time +import os +import random + +from sys import exc_info +from peewee import JOIN_LEFT_OUTER +from app import app, storage, OVERRIDE_CONFIG_DIRECTORY +from workers.worker import Worker +from data.database import Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect + +logger = logging.getLogger(__name__) + +BATCH_SIZE = 20 +INDEXING_INTERVAL = 10 +API_METHOD_INSERT = '/layers' +API_METHOD_VERSION = '/versions/engine' + +def _get_image_to_export(version): + Parent = Image.alias() + ParentImageStorage = ImageStorage.alias() + rimages = [] + + # Without parent + candidates = (Image + .select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum) + .join(ImageStorage) + .where(Image.security_indexed_engine < version, Image.parent >> None, ImageStorage.uploading == False, ImageStorage.checksum != '') + .limit(BATCH_SIZE*10) + .alias('candidates')) + + images = (Image + .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) + .distinct() + .from_(candidates) + .order_by(db_random_func()) + .tuples() + .limit(BATCH_SIZE)) + + 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}) + + # With analyzed parent + 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')) + .join(Parent, on=(Image.parent == Parent.id)) + .join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage)) + .switch(Image) + .join(ImageStorage) + .where(Image.security_indexed_engine < version, Parent.security_indexed == True, Parent.security_indexed_engine >= version, ImageStorage.uploading == False, ImageStorage.checksum != '') + .limit(BATCH_SIZE*10) + .alias('candidates')) + + 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) + .distinct() + .from_(candidates) + .order_by(db_random_func()) + .tuples() + .limit(BATCH_SIZE)) + + 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}) + + # Re-shuffle, otherwise the images without parents will always be on the top + random.shuffle(rimages) + + return rimages + +def _get_storage_locations(uuid): + query = (ImageStoragePlacement + .select() + .join(ImageStorageLocation) + .switch(ImageStoragePlacement) + .join(ImageStorage, JOIN_LEFT_OUTER) + .where(ImageStorage.uuid == uuid)) + + locations = list() + for location in query: + locations.append(location.location.name) + + return locations + +def _update_image(image, indexed, version): + query = (Image + .select() + .join(ImageStorage) + .where(Image.docker_image_id == image['docker_image_id'], ImageStorage.uuid == image['storage_uuid'])) + + updated_images = list() + for image in query: + updated_images.append(image.id) + + query = (Image + .update(security_indexed=indexed, security_indexed_engine=version) + .where(Image.id << updated_images)) + query.execute() + +class SecurityWorker(Worker): + def __init__(self): + super(SecurityWorker, self).__init__() + if self._load_configuration(): + self.add_operation(self._index_images, INDEXING_INTERVAL) + + def _load_configuration(self): + # Load configuration + 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._ca_verification = False + self._cert = None + 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 _index_images(self): + with UseThenDisconnect(app.config): + 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 + 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 + request = { + 'ID': img['docker_image_id']+'.'+img['storage_uuid'], + 'TarSum': img['storage_checksum'], + 'Path': uri + } + if img['parent_docker_image_id'] is not None: + request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid'] + + # Post request + try: + logger.info('Analyzing %s', request['ID']) + # Using invalid certificates doesn't return proper errors because of + # https://github.com/shazow/urllib3/issues/556 + httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, cert=self._cert, verify=self._ca_verification) + except: + logger.exception('An exception occurred when analyzing layer ID %s : %s', request['ID'], exc_info()[0]) + return + try: + jsonResponse = httpResponse.json() + except: + logger.exception('An exception occurred when analyzing layer ID %s : the response is not valid JSON (%s)', request['ID'], httpResponse.text) + return + + if httpResponse.status_code == 201: + # The layer has been successfully indexed + api_version = jsonResponse['Version'] + if api_version < self._target_version: + 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']) + else: + if 'Message' in jsonResponse: + if 'OS and/or package manager are not supported' in jsonResponse['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(img, False, self._target_version) + else: + logger.exception('An exception occurred when analyzing layer ID %s : %d %s', request['ID'], httpResponse.status_code, jsonResponse['Message']) + return + else: + logger.exception('An exception occurred when analyzing layer ID %s : %d', request['ID'], httpResponse.status_code) + return + +if __name__ == '__main__': + logging.getLogger('requests').setLevel(logging.WARNING) + logging.getLogger('apscheduler').setLevel(logging.CRITICAL) + + if not features.SECURITY_SCANNER: + logger.debug('Security scanner disabled; skipping') + while True: + time.sleep(100000) + + worker = SecurityWorker() + worker.start() From a99b8fcfe44fabc45c8add1202a1fa10d9f6e4ab Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Wed, 14 Oct 2015 11:34:02 -0400 Subject: [PATCH 06/17] Fix migration --- .../57dad559ff2d_add_support_for_quay_s_security_indexer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py index 7ba826b14..6272b5d55 100644 --- a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py +++ b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py @@ -14,14 +14,15 @@ import sqlalchemy as sa def upgrade(tables): ### commands auto generated by Alembic - please adjust! ### op.add_column('image', sa.Column('parent_id', sa.Integer(), nullable=True)) - op.add_column('image', sa.Column('security_indexed', sa.Boolean(), nullable=False)) - op.add_column('image', sa.Column('security_indexed_engine', sa.Integer(), nullable=False)) + op.add_column('image', sa.Column('security_indexed', sa.Boolean(), nullable=False, default=False, server_default=sa.sql.expression.false())) + op.add_column('image', sa.Column('security_indexed_engine', sa.Integer(), nullable=False, default=-1, server_default="-1")) op.create_index('image_parent_id', 'image', ['parent_id'], unique=False) op.create_foreign_key(op.f('fk_image_parent_id_image'), 'image', 'image', ['parent_id'], ['id']) ### end Alembic commands ### op.create_index('image_security_indexed_engine_security_indexed', 'image', ['security_indexed_engine', 'security_indexed']) def downgrade(tables): + op.drop_index('image_security_indexed_engine_security_indexed', 'image') ### commands auto generated by Alembic - please adjust! ### op.drop_constraint(op.f('fk_image_parent_id_image'), 'image', type_='foreignkey') op.drop_index('image_parent_id', table_name='image') @@ -29,4 +30,3 @@ def downgrade(tables): op.drop_column('image', 'security_indexed_engine') op.drop_column('image', 'parent_id') ### end Alembic commands ### - op.drop_index('image_security_indexed', 'image') From 1b41200e4985f904a160ef32902d7152d03d78de Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Wed, 14 Oct 2015 13:48:04 -0400 Subject: [PATCH 07/17] Fix PostgresSQL compatibility and parent omittance securityworker --- workers/securityworker.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/workers/securityworker.py b/workers/securityworker.py index 29fdd3dcf..5768ad264 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -33,7 +33,6 @@ def _get_image_to_export(version): images = (Image .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) - .distinct() .from_(candidates) .order_by(db_random_func()) .tuples() @@ -55,14 +54,13 @@ def _get_image_to_export(version): 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) - .distinct() .from_(candidates) .order_by(db_random_func()) .tuples() .limit(BATCH_SIZE)) 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({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': 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 random.shuffle(rimages) @@ -166,7 +164,7 @@ class SecurityWorker(Worker): 'TarSum': img['storage_checksum'], 'Path': uri } - if img['parent_docker_image_id'] is not None: + if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None: request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid'] # Post request From 2d1df267ddcf1f6fff6df33145ddcec91e8c6ace Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 21 Oct 2015 16:35:08 -0400 Subject: [PATCH 08/17] Add security config --- config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config.py b/config.py index fd7813c67..39a2697dc 100644 --- a/config.py +++ b/config.py @@ -250,3 +250,10 @@ class DefaultConfig(object): # Experiment: Async garbage collection EXP_ASYNC_GARBAGE_COLLECTION = [] + + # Security scanner + FEATURE_SECURITY_SCANNER = True + SECURITY_SCANNER = { + 'ENDPOINT': 'http://192.168.99.100:6060', + 'ENGINE_VERSION_TARGET': 1 + } From 36779475214f657cd109ac9883991c55022920a4 Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Mon, 5 Oct 2015 13:35:01 -0400 Subject: [PATCH 09/17] Add support for Quay's vulnerability tool --- .../57dad559ff2d_add_support_for_quay_s_security_indexer.py | 2 +- workers/securityworker.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py index 6272b5d55..c834ab6db 100644 --- a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py +++ b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py @@ -22,8 +22,8 @@ def upgrade(tables): op.create_index('image_security_indexed_engine_security_indexed', 'image', ['security_indexed_engine', 'security_indexed']) def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### op.drop_index('image_security_indexed_engine_security_indexed', 'image') - ### commands auto generated by Alembic - please adjust! ### op.drop_constraint(op.f('fk_image_parent_id_image'), 'image', type_='foreignkey') op.drop_index('image_parent_id', table_name='image') op.drop_column('image', 'security_indexed') diff --git a/workers/securityworker.py b/workers/securityworker.py index 5768ad264..a5702c37e 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -33,6 +33,7 @@ def _get_image_to_export(version): images = (Image .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) + .distinct() .from_(candidates) .order_by(db_random_func()) .tuples() @@ -54,6 +55,7 @@ def _get_image_to_export(version): 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) + .distinct() .from_(candidates) .order_by(db_random_func()) .tuples() @@ -164,6 +166,7 @@ class SecurityWorker(Worker): 'TarSum': img['storage_checksum'], 'Path': uri } + if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None: request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid'] From 0f3db709ea5930c227394a4e3099974b58b4a863 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 13 Oct 2015 18:14:52 -0400 Subject: [PATCH 10/17] Add a vulnerability_found event for notice when we detect a vuln Fixes #637 Note: This PR does *not* actually raise the event; it merely adds support for it --- config.py | 2 +- data/database.py | 1 + .../50925110da8c_add_event_specific_config.py | 27 +++ ...add_support_for_quay_s_security_indexer.py | 2 +- ...dc2d819c5_add_vulnerability_found_event.py | 41 ++++ data/model/notification.py | 5 +- endpoints/api/repositorynotification.py | 5 + endpoints/notificationevent.py | 34 +++ events/vulnerability_found.html | 4 + initdb.py | 4 +- .../create-external-notification-dialog.css | 20 ++ .../create-external-notification-dialog.html | 210 ++++++++++-------- static/js/directives/object-order-by.js | 17 ++ .../ui/create-external-notification-dialog.js | 3 + .../js/services/external-notification-data.js | 20 +- static/js/services/notification-service.js | 22 +- static/js/services/string-builder-service.js | 109 +++++---- static/js/services/vulnerability-service.js | 98 ++++++++ util/migrate/backfill_checksums.py | 11 +- 19 files changed, 476 insertions(+), 159 deletions(-) create mode 100644 data/migrations/versions/50925110da8c_add_event_specific_config.py create mode 100644 data/migrations/versions/5cdc2d819c5_add_vulnerability_found_event.py create mode 100644 events/vulnerability_found.html create mode 100644 static/css/directives/ui/create-external-notification-dialog.css create mode 100644 static/js/directives/object-order-by.js create mode 100644 static/js/services/vulnerability-service.js diff --git a/config.py b/config.py index 39a2697dc..b049152d0 100644 --- a/config.py +++ b/config.py @@ -255,5 +255,5 @@ class DefaultConfig(object): FEATURE_SECURITY_SCANNER = True SECURITY_SCANNER = { 'ENDPOINT': 'http://192.168.99.100:6060', - 'ENGINE_VERSION_TARGET': 1 + 'ENGINE_VERSION_TARGET': 1, } diff --git a/data/database.py b/data/database.py index 305a34436..c87ece328 100644 --- a/data/database.py +++ b/data/database.py @@ -751,6 +751,7 @@ class RepositoryNotification(BaseModel): method = ForeignKeyField(ExternalNotificationMethod) title = CharField(null=True) config_json = TextField() + event_config_json = TextField(default='{}') class RepositoryAuthorizedEmail(BaseModel): diff --git a/data/migrations/versions/50925110da8c_add_event_specific_config.py b/data/migrations/versions/50925110da8c_add_event_specific_config.py new file mode 100644 index 000000000..eb9e9c1c8 --- /dev/null +++ b/data/migrations/versions/50925110da8c_add_event_specific_config.py @@ -0,0 +1,27 @@ +"""Add event-specific config + +Revision ID: 50925110da8c +Revises: 2fb9492c20cc +Create Date: 2015-10-13 18:03:14.859839 + +""" + +# revision identifiers, used by Alembic. +revision = '50925110da8c' +down_revision = '2fb9492c20cc' + +from alembic import op +import sqlalchemy as sa +from util.migrate import UTF8LongText + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('repositorynotification', sa.Column('event_config_json', UTF8LongText, nullable=False)) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('repositorynotification', 'event_config_json') + ### end Alembic commands ### diff --git a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py index c834ab6db..7cd0f84c4 100644 --- a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py +++ b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py @@ -6,7 +6,7 @@ Create Date: 2015-07-13 16:51:41.669249 # revision identifiers, used by Alembic. revision = '57dad559ff2d' -down_revision = '3ff4fbc94644' +down_revision = '35f538da62' from alembic import op import sqlalchemy as sa diff --git a/data/migrations/versions/5cdc2d819c5_add_vulnerability_found_event.py b/data/migrations/versions/5cdc2d819c5_add_vulnerability_found_event.py new file mode 100644 index 000000000..76051323a --- /dev/null +++ b/data/migrations/versions/5cdc2d819c5_add_vulnerability_found_event.py @@ -0,0 +1,41 @@ +"""Add vulnerability_found event + +Revision ID: 5cdc2d819c5 +Revises: 50925110da8c +Create Date: 2015-10-13 18:05:32.157858 + +""" + +# revision identifiers, used by Alembic. +revision = '5cdc2d819c5' +down_revision = '50925110da8c' + +from alembic import op +import sqlalchemy as sa + + + +def upgrade(tables): + op.bulk_insert(tables.externalnotificationevent, + [ + {'id':6, 'name':'vulnerability_found'}, + ]) + + op.bulk_insert(tables.notificationkind, + [ + {'id':11, 'name':'vulnerability_found'}, + ]) + + +def downgrade(tables): + op.execute( + (tables.externalnotificationevent.delete() + .where(tables.externalnotificationevent.c.name == op.inline_literal('vulnerability_found'))) + + ) + + op.execute( + (tables.notificationkind.delete() + .where(tables.notificationkind.c.name == op.inline_literal('vulnerability_found'))) + + ) \ No newline at end of file diff --git a/data/model/notification.py b/data/model/notification.py index 87ae7f7ca..409d6c000 100644 --- a/data/model/notification.py +++ b/data/model/notification.py @@ -113,12 +113,13 @@ def delete_matching_notifications(target, kind_name, **kwargs): notification.delete_instance() -def create_repo_notification(repo, event_name, method_name, config, title=None): +def create_repo_notification(repo, event_name, method_name, method_config, event_config, title=None): event = ExternalNotificationEvent.get(ExternalNotificationEvent.name == event_name) method = ExternalNotificationMethod.get(ExternalNotificationMethod.name == method_name) return RepositoryNotification.create(repository=repo, event=event, method=method, - config_json=json.dumps(config), title=title) + config_json=json.dumps(method_config), title=title, + event_config_json=json.dumps(event_config)) def get_repo_notification(uuid): diff --git a/endpoints/api/repositorynotification.py b/endpoints/api/repositorynotification.py index 832328cbe..30c71cf54 100644 --- a/endpoints/api/repositorynotification.py +++ b/endpoints/api/repositorynotification.py @@ -57,6 +57,10 @@ class RepositoryNotificationList(RepositoryParamResource): 'type': 'object', 'description': 'JSON config information for the specific method of notification' }, + 'eventConfig': { + 'type': 'object', + 'description': 'JSON config information for the specific event of notification', + }, 'title': { 'type': 'string', 'description': 'The human-readable title of the notification', @@ -84,6 +88,7 @@ class RepositoryNotificationList(RepositoryParamResource): new_notification = model.notification.create_repo_notification(repo, parsed['event'], parsed['method'], parsed['config'], + parsed['eventConfig'], parsed.get('title', None)) resp = notification_view(new_notification) diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py index b1d319a1f..ebd7e10b6 100644 --- a/endpoints/notificationevent.py +++ b/endpoints/notificationevent.py @@ -84,6 +84,40 @@ def _build_summary(event_data): return summary +class VulnerabilityFoundEvent(NotificationEvent): + @classmethod + def event_name(cls): + return 'vulnerability_found' + + def get_level(self, event_data, notification_data): + priority = event_data['vulnerability']['priority'] + if priority == 'Defcon1' or priority == 'Critical': + return 'error' + + if priority == 'Medium' or priority == 'High': + return 'warning' + + return 'info' + + def get_sample_data(self, repository): + return build_event_data(repository, { + 'tags': ['latest', 'prod'], + 'image': 'some-image-id', + 'vulnerability': { + 'id': 'CVE-FAKE-CVE', + 'description': 'A futurist vulnerability', + 'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE', + 'priority': 'Critical', + }, + }) + + def get_summary(self, event_data, notification_data): + msg = '%s vulnerability detected in repository %s in tags %s' + return msg % (event_data['vulnerability']['priority'], + event_data['repository'], + ', '.join(event_data['tags'])) + + class BuildQueueEvent(NotificationEvent): @classmethod def event_name(cls): diff --git a/events/vulnerability_found.html b/events/vulnerability_found.html new file mode 100644 index 000000000..f20f4053b --- /dev/null +++ b/events/vulnerability_found.html @@ -0,0 +1,4 @@ +A {{ event_data.vulnerability.priority }} vulnerability ({{ event_data.vulnerability.id }}) was detected in tags + {{ 'tags' | icon_image }} +{% for tag in event_data.tags %}{%if loop.index > 1 %}, {% endif %}{{ (event_data.repository, tag) | repository_tag_reference }}{% endfor %} in + repository {{ event_data.repository | repository_reference }} \ No newline at end of file diff --git a/initdb.py b/initdb.py index 357afd5e2..80c9fa952 100644 --- a/initdb.py +++ b/initdb.py @@ -95,7 +95,7 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): for path_builder in paths: path = path_builder(new_image.storage.uuid) store.put_content('local_us', path, checksum) - + new_image.security_indexed = False new_image.security_indexed_engine = maxsize new_image.save() @@ -314,6 +314,7 @@ def initialize_database(): ExternalNotificationEvent.create(name='build_start') ExternalNotificationEvent.create(name='build_success') ExternalNotificationEvent.create(name='build_failure') + ExternalNotificationEvent.create(name='vulnerability_found') ExternalNotificationMethod.create(name='quay_notification') ExternalNotificationMethod.create(name='email') @@ -328,6 +329,7 @@ def initialize_database(): NotificationKind.create(name='build_start') NotificationKind.create(name='build_success') NotificationKind.create(name='build_failure') + NotificationKind.create(name='vulnerability_found') NotificationKind.create(name='password_required') NotificationKind.create(name='over_private_usage') diff --git a/static/css/directives/ui/create-external-notification-dialog.css b/static/css/directives/ui/create-external-notification-dialog.css new file mode 100644 index 000000000..12394955c --- /dev/null +++ b/static/css/directives/ui/create-external-notification-dialog.css @@ -0,0 +1,20 @@ +#createNotificationModal .dropdown-select { + margin: 0px; +} + +#createNotificationModal .options-table { + width: 100%; + margin-bottom: 10px; +} + +#createNotificationModal .options-table td { + padding-bottom: 6px; +} + +#createNotificationModal .options-table td.name { + width: 160px; +} + +#createNotificationModal .options-table-wrapper { + padding: 10px; +} \ No newline at end of file diff --git a/static/directives/create-external-notification-dialog.html b/static/directives/create-external-notification-dialog.html index 592efc322..b249e819f 100644 --- a/static/directives/create-external-notification-dialog.html +++ b/static/directives/create-external-notification-dialog.html @@ -1,12 +1,12 @@ -