From 3db4c15459e75e408f632422e0ca63c2c210fbab Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 9 Feb 2017 17:28:39 -0800 Subject: [PATCH] Pull out security scanner validation into validator class --- util/config/validator.py | 30 +------ util/config/validators/test/conftest.py | 86 +++++++++++++++++++ .../validators/test/test_validate_secscan.py | 36 ++++++++ util/config/validators/validate_secscan.py | 36 ++++++++ util/secscan/fake.py | 9 +- 5 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 util/config/validators/test/conftest.py create mode 100644 util/config/validators/test/test_validate_secscan.py create mode 100644 util/config/validators/validate_secscan.py diff --git a/util/config/validator.py b/util/config/validator.py index f184a9264..9724a8cf2 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -1,5 +1,4 @@ import logging -import time from StringIO import StringIO from hashlib import sha1 @@ -11,13 +10,11 @@ from flask import Flask from app import app, config_provider, get_app_url, OVERRIDE_CONFIG_DIRECTORY from auth.auth_context import get_authenticated_user from bitbucket import BitBucket -from boot import setup_jwt_proxy from data.database import validate_database_url from data.users import LDAP_CERT_FILENAME from oauth.services.github import GithubOAuthService from oauth.services.google import GoogleOAuthService from oauth.services.gitlab import GitLabOAuthService -from util.secscan.api import SecurityScannerAPI from util.registry.torrent import torrent_jwt from util.security.signing import SIGNING_ENGINES from util.security.ssl import load_certificate, CertInvalidException, KeyInvalidException @@ -29,6 +26,7 @@ from util.config.validators.validate_email import EmailValidator from util.config.validators.validate_ldap import LDAPValidator from util.config.validators.validate_keystone import KeystoneValidator from util.config.validators.validate_jwt import JWTAuthValidator +from util.config.validators.validate_secscan import SecurityScannerValidator logger = logging.getLogger(__name__) @@ -248,30 +246,6 @@ def _validate_signer(config, user_obj, _): engine.detached_sign(StringIO('test string')) -def _validate_security_scanner(config, user_obj, _): - """ Validates the configuration for talking to a Quay Security Scanner. """ - client = app.config['HTTPCLIENT'] - api = SecurityScannerAPI(app, config, None, client=client, skip_validation=True) - - if not config.get('TESTING', False): - # Generate a temporary Quay key to use for signing the outgoing requests. - setup_jwt_proxy() - - # We have to wait for JWT proxy to restart with the newly generated key. - max_tries = 5 - response = None - while max_tries > 0: - response = api.ping() - if response.status_code == 200: - return - - time.sleep(1) - max_tries = max_tries - 1 - - message = 'Expected 200 status code, got %s: %s' % (response.status_code, response.text) - raise ConfigValidationException('Could not ping security scanner: %s' % message) - - def _validate_bittorrent(config, user_obj, _): """ Validates the configuration for using BitTorrent for downloads. """ announce_url = config.get('BITTORRENT_ANNOUNCE_URL') @@ -328,6 +302,6 @@ VALIDATORS = { JWTAuthValidator.name: JWTAuthValidator.validate, KeystoneValidator.name: KeystoneValidator.validate, 'signer': _validate_signer, - 'security-scanner': _validate_security_scanner, + SecurityScannerValidator.name: SecurityScannerValidator.validate, 'bittorrent': _validate_bittorrent, } diff --git a/util/config/validators/test/conftest.py b/util/config/validators/test/conftest.py new file mode 100644 index 000000000..707fc24ca --- /dev/null +++ b/util/config/validators/test/conftest.py @@ -0,0 +1,86 @@ +import os + +import pytest +import shutil +from flask import Flask, jsonify +from flask.ext.login import LoginManager +from peewee import SqliteDatabase + +from app import app as application +from data import model +from data.database import (close_db_filter, db) +from data.model.user import LoginWrappedDBUser +from endpoints.api import api_bp +from initdb import initialize_database, populate_database +from path_converters import APIRepositoryPathConverter, RegexConverter + + +@pytest.fixture() +def app(appconfig): + """ Used by pytest-flask plugin to inject app by test for client See test_security by name injection of client. """ + app = Flask(__name__) + login_manager = LoginManager(app) + + @app.errorhandler(model.DataModelException) + def handle_dme(ex): + response = jsonify({'message': ex.message}) + response.status_code = 400 + return response + + @login_manager.user_loader + def load_user(user_uuid): + return LoginWrappedDBUser(user_uuid) + + app.url_map.converters['regex'] = RegexConverter + app.url_map.converters['apirepopath'] = APIRepositoryPathConverter + app.register_blueprint(api_bp, url_prefix='/api') + app.config.update(appconfig) + return app + + +@pytest.fixture(scope="session") +def init_db_path(tmpdir_factory): + """ Creates a new db and appropriate configuration. Used for parameter by name injection. """ + sqlitedb_file = str(tmpdir_factory.mktemp("data").join("test.db")) + sqlitedb = 'sqlite:///{0}'.format(sqlitedb_file) + conf = {"TESTING": True, + "DEBUG": True, + "DB_URI": sqlitedb} + os.environ['TEST_DATABASE_URI'] = str(sqlitedb) + os.environ['DB_URI'] = str(sqlitedb) + db.initialize(SqliteDatabase(sqlitedb_file)) + application.config.update(conf) + application.config.update({"DB_URI": sqlitedb}) + initialize_database() + populate_database() + close_db_filter(None) + return str(sqlitedb_file) + + +@pytest.fixture() +def database_uri(monkeypatch, init_db_path, sqlitedb_file): + """ Creates the db uri. Used for parameter by name injection. """ + shutil.copy2(init_db_path, sqlitedb_file) + db.initialize(SqliteDatabase(sqlitedb_file)) + db_path = 'sqlite:///{0}'.format(sqlitedb_file) + monkeypatch.setenv("DB_URI", db_path) + return db_path + + +@pytest.fixture() +def sqlitedb_file(tmpdir): + """ Makes file for db. Used for parameter by name injection. """ + test_db_file = tmpdir.mkdir("quaydb").join("test.db") + return str(test_db_file) + + +@pytest.fixture() +def appconfig(database_uri): + """ Makes conf with database_uri. Used for parameter by name injection """ + conf = { + "TESTING": True, + "DEBUG": True, + "DB_URI": database_uri, + "SECRET_KEY": 'superdupersecret!!!1', + } + return conf diff --git a/util/config/validators/test/test_validate_secscan.py b/util/config/validators/test/test_validate_secscan.py new file mode 100644 index 000000000..e47aa9bf5 --- /dev/null +++ b/util/config/validators/test/test_validate_secscan.py @@ -0,0 +1,36 @@ +import pytest + +from util.config.validators import ConfigValidationException +from util.config.validators.validate_secscan import SecurityScannerValidator +from util.secscan.fake import fake_security_scanner + +@pytest.mark.parametrize('unvalidated_config', [ + ({'DISTRIBUTED_STORAGE_PREFERENCE': []}), +]) +def test_validate_noop(unvalidated_config, app): + SecurityScannerValidator.validate(unvalidated_config, None, None) + + +@pytest.mark.parametrize('unvalidated_config, expected_error, error_message', [ + ({ + 'TESTING': True, + 'DISTRIBUTED_STORAGE_PREFERENCE': [], + 'FEATURE_SECURITY_SCANNER': True, + 'SECURITY_SCANNER_ENDPOINT': 'http://invalidhost', + }, Exception, 'Connection error when trying to connect to security scanner endpoint'), + + ({ + 'TESTING': True, + 'DISTRIBUTED_STORAGE_PREFERENCE': [], + 'FEATURE_SECURITY_SCANNER': True, + 'SECURITY_SCANNER_ENDPOINT': 'http://fakesecurityscanner', + }, None, None), +]) +def test_validate(unvalidated_config, expected_error, error_message, app): + with fake_security_scanner(hostname='fakesecurityscanner'): + if expected_error is not None: + with pytest.raises(expected_error) as ipe: + SecurityScannerValidator.validate(unvalidated_config, None, None) + assert ipe.value.message == error_message + else: + SecurityScannerValidator.validate(unvalidated_config, None, None) diff --git a/util/config/validators/validate_secscan.py b/util/config/validators/validate_secscan.py new file mode 100644 index 000000000..14cf0e81b --- /dev/null +++ b/util/config/validators/validate_secscan.py @@ -0,0 +1,36 @@ +import time + +from app import app +from boot import setup_jwt_proxy +from util.secscan.api import SecurityScannerAPI +from util.config.validators import BaseValidator, ConfigValidationException + +class SecurityScannerValidator(BaseValidator): + name = "security-scanner" + + @classmethod + def validate(cls, config, user, user_password): + """ Validates the configuration for talking to a Quay Security Scanner. """ + if not config.get('FEATURE_SECURITY_SCANNER', False): + return + + client = app.config['HTTPCLIENT'] + api = SecurityScannerAPI(app, config, None, client=client, skip_validation=True) + + if not config.get('TESTING', False): + # Generate a temporary Quay key to use for signing the outgoing requests. + setup_jwt_proxy() + + # We have to wait for JWT proxy to restart with the newly generated key. + max_tries = 5 + response = None + while max_tries > 0: + response = api.ping() + if response.status_code == 200: + return + + time.sleep(1) + max_tries = max_tries - 1 + + message = 'Expected 200 status code, got %s: %s' % (response.status_code, response.text) + raise ConfigValidationException('Could not ping security scanner: %s' % message) diff --git a/util/secscan/fake.py b/util/secscan/fake.py index 0f39f3ff9..849d0c2ca 100644 --- a/util/secscan/fake.py +++ b/util/secscan/fake.py @@ -316,6 +316,13 @@ class FakeSecurityScanner(object): 'content': json.dumps(response), } + @urlmatch(netloc=r'(.*\.)?' + self.hostname, path=r'/v1/metrics$', method='GET') + def metrics(url, _): + return { + 'status_code': 200, + 'content': json.dumps({'fake': True}), + } + @all_requests def response_content(url, _): return { @@ -324,4 +331,4 @@ class FakeSecurityScanner(object): } return [get_layer_mock, post_layer_mock, remove_layer_mock, get_notification, - delete_notification, response_content] + delete_notification, metrics, response_content]