parent
53ce4de6aa
commit
2cbdecb043
23 changed files with 584 additions and 116 deletions
2
app.py
2
app.py
|
@ -194,7 +194,7 @@ 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_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf)
|
secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf)
|
||||||
secscan_api = SecurityScannerAPI(app.config, config_provider, storage)
|
secscan_api = SecurityScannerAPI(app.config, storage)
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
39
boot.py
39
boot.py
|
@ -5,8 +5,9 @@ from urlparse import urlunparse
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from cachetools import lru_cache
|
from cachetools import lru_cache
|
||||||
import release
|
|
||||||
|
|
||||||
|
import release
|
||||||
|
import os.path
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from data.model.release import set_region_release
|
from data.model.release import set_region_release
|
||||||
|
@ -37,49 +38,49 @@ def get_audience():
|
||||||
return urlunparse((scheme, hostname + ':' + port, '', '', '', ''))
|
return urlunparse((scheme, hostname + ':' + port, '', '', '', ''))
|
||||||
|
|
||||||
|
|
||||||
def create_quay_service_key():
|
def setup_jwt_proxy():
|
||||||
"""
|
"""
|
||||||
Creates a service key for quay to use in the jwtproxy
|
Creates a service key for quay to use in the jwtproxy and generates the JWT proxy configuration.
|
||||||
"""
|
"""
|
||||||
|
if os.path.exists('conf/jwtproxy_conf.yaml'):
|
||||||
|
# Proxy is already setup.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate the key for this Quay instance to use.
|
||||||
minutes_until_expiration = app.config.get('QUAY_SERVICE_KEY_EXPIRATION', 120)
|
minutes_until_expiration = app.config.get('QUAY_SERVICE_KEY_EXPIRATION', 120)
|
||||||
expiration = datetime.now() + timedelta(minutes=minutes_until_expiration)
|
expiration = datetime.now() + timedelta(minutes=minutes_until_expiration)
|
||||||
quay_key, key_id = generate_key('quay', get_audience(), expiration_date=expiration)
|
quay_key, quay_key_id = generate_key('quay', get_audience(), expiration_date=expiration)
|
||||||
|
|
||||||
with open('/conf/quay.kid', mode='w') as f:
|
with open('conf/quay.kid', mode='w') as f:
|
||||||
f.truncate(0)
|
f.truncate(0)
|
||||||
f.write(key_id)
|
f.write(quay_key_id)
|
||||||
|
|
||||||
with open('/conf/quay.pem', mode='w') as f:
|
with open('conf/quay.pem', mode='w') as f:
|
||||||
f.truncate(0)
|
f.truncate(0)
|
||||||
f.write(quay_key.exportKey())
|
f.write(quay_key.exportKey())
|
||||||
|
|
||||||
return key_id
|
# Generate the JWT proxy configuration.
|
||||||
|
|
||||||
|
|
||||||
def create_jwtproxy_conf(quay_key_id):
|
|
||||||
"""
|
|
||||||
Generates the jwtproxy conf from the jinja template
|
|
||||||
"""
|
|
||||||
audience = get_audience()
|
audience = get_audience()
|
||||||
registry = audience + '/keys'
|
registry = audience + '/keys'
|
||||||
|
security_issuer = app.config.get('SECURITY_SCANNER_ISSUER_NAME', 'security_scanner')
|
||||||
|
|
||||||
with open("/conf/jwtproxy_conf.yaml.jnj") as f:
|
with open("conf/jwtproxy_conf.yaml.jnj") as f:
|
||||||
template = Template(f.read())
|
template = Template(f.read())
|
||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
audience=audience,
|
audience=audience,
|
||||||
registry=registry,
|
registry=registry,
|
||||||
key_id=quay_key_id
|
key_id=quay_key_id,
|
||||||
|
security_issuer=security_issuer,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open('/conf/jwtproxy_conf.yaml', 'w') as f:
|
with open('conf/jwtproxy_conf.yaml', 'w') as f:
|
||||||
f.write(rendered)
|
f.write(rendered)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if app.config.get('SETUP_COMPLETE', False):
|
if app.config.get('SETUP_COMPLETE', False):
|
||||||
sync_database_with_config(app.config)
|
sync_database_with_config(app.config)
|
||||||
quay_key_id = create_quay_service_key()
|
setup_jwt_proxy()
|
||||||
create_jwtproxy_conf(quay_key_id)
|
|
||||||
|
|
||||||
# Record deploy
|
# Record deploy
|
||||||
if release.REGION and release.GIT_HEAD:
|
if release.REGION and release.GIT_HEAD:
|
||||||
|
|
|
@ -23,5 +23,5 @@ jwtproxy:
|
||||||
key_server:
|
key_server:
|
||||||
type: keyregistry
|
type: keyregistry
|
||||||
options:
|
options:
|
||||||
issuer: clair
|
issuer: {{ security_issuer }}
|
||||||
registry: {{ registry }}
|
registry: {{ registry }}
|
||||||
|
|
31
config.py
31
config.py
|
@ -282,18 +282,33 @@ class DefaultConfig(object):
|
||||||
# Security scanner
|
# Security scanner
|
||||||
FEATURE_SECURITY_SCANNER = False
|
FEATURE_SECURITY_SCANNER = False
|
||||||
FEATURE_SECURITY_NOTIFICATIONS = False
|
FEATURE_SECURITY_NOTIFICATIONS = False
|
||||||
SECURITY_SCANNER = {
|
|
||||||
'ENDPOINT': 'http://192.168.99.101:6060',
|
# The endpoint for the security scanner.
|
||||||
'ENGINE_VERSION_TARGET': 2,
|
SECURITY_SCANNER_ENDPOINT = 'http://192.168.99.101:6060'
|
||||||
'API_VERSION': 'v1',
|
|
||||||
'API_TIMEOUT_SECONDS': 10,
|
# If specified, the endpoint to be used for all POST calls to the security scanner.
|
||||||
'API_TIMEOUT_POST_SECONDS': 480,
|
SECURITY_SCANNER_ENDPOINT_BATCH = None
|
||||||
}
|
|
||||||
|
# The indexing engine version running inside the security scanner.
|
||||||
|
SECURITY_SCANNER_ENGINE_VERSION_TARGET = 2
|
||||||
|
|
||||||
|
# The version of the API to use for the security scanner.
|
||||||
|
SECURITY_SCANNER_API_VERSION = 'v1'
|
||||||
|
|
||||||
|
# API call timeout for the security scanner.
|
||||||
|
SECURITY_SCANNER_API_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
# POST call timeout for the security scanner.
|
||||||
|
SECURITY_SCANNER_API_TIMEOUT_POST_SECONDS = 480
|
||||||
|
|
||||||
|
# The issuer name for the security scanner.
|
||||||
|
SECURITY_SCANNER_ISSUER_NAME = 'security_scanner'
|
||||||
|
|
||||||
# JWTProxy Settings
|
# JWTProxy Settings
|
||||||
# The address (sans schema) to proxy outgoing requests through the jwtproxy
|
# The address (sans schema) to proxy outgoing requests through the jwtproxy
|
||||||
# to be signed
|
# to be signed
|
||||||
JWTPROXY_SIGNER = 'localhost:8080'
|
JWTPROXY_SIGNER = 'localhost:8080'
|
||||||
|
|
||||||
# The audience that jwtproxy should verify on incoming requests
|
# The audience that jwtproxy should verify on incoming requests
|
||||||
# If None, will be calculated off of the SERVER_HOSTNAME (default)
|
# If None, will be calculated off of the SERVER_HOSTNAME (default)
|
||||||
JWTPROXY_AUDIENCE = None
|
JWTPROXY_AUDIENCE = None
|
||||||
|
@ -322,7 +337,9 @@ class DefaultConfig(object):
|
||||||
|
|
||||||
# The location of the private key generated for this instance
|
# The location of the private key generated for this instance
|
||||||
INSTANCE_SERVICE_KEY_LOCATION = 'conf/quay.pem'
|
INSTANCE_SERVICE_KEY_LOCATION = 'conf/quay.pem'
|
||||||
|
|
||||||
# This instance's service key expiration in minutes
|
# This instance's service key expiration in minutes
|
||||||
INSTANCE_SERVICE_KEY_EXPIRATION = 120
|
INSTANCE_SERVICE_KEY_EXPIRATION = 120
|
||||||
|
|
||||||
# Number of minutes between expiration refresh in minutes
|
# Number of minutes between expiration refresh in minutes
|
||||||
INSTANCE_SERVICE_KEY_REFRESH = 60
|
INSTANCE_SERVICE_KEY_REFRESH = 60
|
||||||
|
|
|
@ -599,6 +599,7 @@ class SuperUserServiceKeyManagement(ApiResource):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'kid': key.kid,
|
'kid': key.kid,
|
||||||
'name': body.get('name', ''),
|
'name': body.get('name', ''),
|
||||||
|
'service': body['service'],
|
||||||
'public_key': private_key.publickey().exportKey('PEM'),
|
'public_key': private_key.publickey().exportKey('PEM'),
|
||||||
'private_key': private_key.exportKey('PEM'),
|
'private_key': private_key.exportKey('PEM'),
|
||||||
})
|
})
|
||||||
|
|
|
@ -545,6 +545,18 @@ a:focus {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-service-key-field-element {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-service-key-field-element .co-modify-link {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-service-key-field-element .fa-check {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.co-checkbox {
|
.co-checkbox {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
@ -1457,4 +1469,11 @@ a:focus {
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.co-option-table .help-text {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
11
static/css/directives/ui/request-service-key-dialog.css
Normal file
11
static/css/directives/ui/request-service-key-dialog.css
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.request-service-key-dialog-element .co-option-table {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-service-key-dialog-element .key-display {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: Menlo,Monaco,Consolas,"Courier New",monospace;
|
||||||
|
background: white;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
29
static/directives/config/config-service-key-field.html
Normal file
29
static/directives/config/config-service-key-field.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<div class="config-service-key-field-element">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div class="cor-loader" ng-if="loading"></div>
|
||||||
|
|
||||||
|
<!-- Loading error -->
|
||||||
|
<div class="co-alert co-alert-warning" ng-if="loadError">
|
||||||
|
Could not load service keys
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key config -->
|
||||||
|
<div ng-show="!loading && !loadError">
|
||||||
|
<div ng-show="hasValidKey">
|
||||||
|
<i class="fa fa-check"></i>
|
||||||
|
Valid key for service <code>{{ serviceName }}</code> exists
|
||||||
|
</div>
|
||||||
|
<div ng-show="!hasValidKey">
|
||||||
|
No valid key found for service <code>{{ serviceName }}</code>
|
||||||
|
<a class="co-modify-link" ng-click="showRequestServiceKey()">Create Key</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note: This field is a hidden text field that binds to a model that is set to non-empty
|
||||||
|
when there is a valid key. This allows us to use the existing Angular form validation
|
||||||
|
code.
|
||||||
|
-->
|
||||||
|
<input type="text" ng-model="hasValidKeyStr" ng-required="true" style="position: absolute; top: 0px; left: 0px; visibility: hidden; width: 0px; height: 0px;">
|
||||||
|
|
||||||
|
<div class="request-service-key-dialog" request-key-info="requestKeyInfo" key-created="handleKeyCreated(key)"></div>
|
||||||
|
</div>
|
|
@ -286,6 +286,53 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Scanner -->
|
||||||
|
<div class="co-panel">
|
||||||
|
<div class="co-panel-heading">
|
||||||
|
<i class="fa fa-bug"></i> Security Scanner
|
||||||
|
</div>
|
||||||
|
<div class="co-panel-body">
|
||||||
|
<div class="description">
|
||||||
|
<p>If enabled, all images pushed to Quay will be scanned via the external security scanning service, with vulnerability information available in the UI and API, as well
|
||||||
|
as async notification support.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="co-checkbox">
|
||||||
|
<input id="ftsecurity" type="checkbox" ng-model="config.FEATURE_SECURITY_SCANNER">
|
||||||
|
<label for="ftsecurity">Enable Security Scanning</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="co-alert co-alert-info" ng-if="config.FEATURE_SECURITY_SCANNER" style="margin-top: 20px;">
|
||||||
|
A scanner compliant with the Quay Security Scanning API must be running to use this feature. Documentation on running <a href="https://github.com/coreos/clair" ng-safenewtab>Clair</a> can be found at <a href="https://tectonic.com/quay-enterprise/docs/latest/clair.html" ng-safenewtab>Running Clair Security Scanner</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="config-table" ng-if="config.FEATURE_SECURITY_SCANNER">
|
||||||
|
<tr>
|
||||||
|
<td>Security Scanner Endpoint:</td>
|
||||||
|
<td>
|
||||||
|
<span class="config-string-field" binding="config.SECURITY_SCANNER_ENDPOINT"
|
||||||
|
placeholder="Security Scanner API endpoint (Example: http://myhost:6060)"
|
||||||
|
pattern="http(s)?://.+"></span>
|
||||||
|
<div class="help-text">
|
||||||
|
The HTTP URL at which the security scanner is running.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Authentication Key:</td>
|
||||||
|
<td>
|
||||||
|
<span class="config-service-key-field" service-name="{{ config.SECURITY_SCANNER_ISSUER_NAME }}"></span>
|
||||||
|
<div class="help-text">
|
||||||
|
The security scanning service requires an authorized service key to speak to Quay. Once setup, the key
|
||||||
|
can be managed in the Service Keys panel under the Super User Admin Panel.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ACI Conversion -->
|
<!-- ACI Conversion -->
|
||||||
<div class="co-panel">
|
<div class="co-panel">
|
||||||
<div class="co-panel-heading">
|
<div class="co-panel-heading">
|
||||||
|
|
134
static/directives/request-service-key-dialog.html
Normal file
134
static/directives/request-service-key-dialog.html
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
<div class="request-service-key-dialog-element">
|
||||||
|
<!-- Modal message dialog -->
|
||||||
|
<div class="co-dialog modal fade">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-show="!working">×</button>
|
||||||
|
<h4 class="modal-title">Create key for service {{ requestKeyInfo.service }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body" ng-show="working">
|
||||||
|
<div class="cor-loader"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body" ng-show="!working">
|
||||||
|
<!-- Step 0 -->
|
||||||
|
<div ng-show="step == 0">
|
||||||
|
<table class="co-option-table">
|
||||||
|
<tr>
|
||||||
|
<td><input type="radio" id="automaticKey" ng-model="requestKind" value="automatic"></td>
|
||||||
|
<td>
|
||||||
|
<label for="automaticKey">Have the service provide a key</label>
|
||||||
|
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the single instance is setup now.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><input type="radio" id="presharedKey" ng-model="requestKind" value="preshared"></td>
|
||||||
|
<td>
|
||||||
|
<label for="presharedKey">Generate shared key</label>
|
||||||
|
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the instances are dynamically started.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1 (automatic) -->
|
||||||
|
<div ng-show="step == 1 && requestKind == 'automatic'" style="text-align: center">
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
Please start the <code>{{ requestKeyInfo.service }}</code> service now, configured for <a href="https://github.com/coreos/jwtproxy#autogenerated-private-key" ng-safenewtab>autogenerated private key</a>. The key approval process will continue automatically once the service connects to Quay.
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
Waiting for service to connect
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px; margin-bottom: 20px;">
|
||||||
|
<div class="cor-loader-inline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 (automatic) -->
|
||||||
|
<div ng-show="step == 2 && requestKind == 'automatic'" style="text-align: center">
|
||||||
|
A key for service <code>{{ requestKeyInfo.service }}</code> has been automatically generated, approved and saved in the service's keystore.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1 (generate) -->
|
||||||
|
<div ng-show="step == 1 && requestKind == 'preshared'">
|
||||||
|
<form name="createForm" ng-submit="createPresharedKey()">
|
||||||
|
<table class="co-form-table">
|
||||||
|
<tr>
|
||||||
|
<td><label for="create-key-name">Key Name:</label></td>
|
||||||
|
<td>
|
||||||
|
<input class="form-control" name="create-key-name" type="text" ng-model="preshared.name" placeholder="Friendly Key Name">
|
||||||
|
<span class="co-help-text">
|
||||||
|
A friendly name for the key for later reference.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="create-key-expiration">Expiration date (optional):</label></td>
|
||||||
|
<td>
|
||||||
|
<span class="datetime-picker" datetime="preshared.expiration"></span>
|
||||||
|
<span class="co-help-text">
|
||||||
|
The date and time that the key expires. If left blank, the key will never expire.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="create-key-notes">Approval Notes (optional):</label></td>
|
||||||
|
<td>
|
||||||
|
<div class="markdown-editor" content="preshared.notes"></div>
|
||||||
|
<span class="co-help-text">
|
||||||
|
Optional notes for additional human-readable information about why the key was created.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 (generate) -->
|
||||||
|
<div ng-show="step == 2 && requestKind == 'preshared'">
|
||||||
|
<div class="co-alert co-alert-warning">
|
||||||
|
The following key has been generated for service <code>{{ requestKeyInfo.service }}</code>.
|
||||||
|
<br><br>
|
||||||
|
Please copy the key's ID and copy/download the key's private contents and place it in the directory with the service's configuration.
|
||||||
|
<br><br>
|
||||||
|
<strong>Once this dialog is closed this private key will not be accessible anywhere else!</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Key ID:</label>
|
||||||
|
<div class="copy-box" value="createdKey.kid"></div>
|
||||||
|
|
||||||
|
<label>Private Key (PEM):</label>
|
||||||
|
<textarea class="key-display form-control" onclick="this.focus();this.select()" readonly>{{ createdKey.private_key }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" ng-show="!working">
|
||||||
|
<button type="button" class="btn btn-primary" ng-show="step == 1 && requestKind == 'preshared'"
|
||||||
|
ng-disabled="createForm.$invalid"
|
||||||
|
ng-click="createPresharedKey()">
|
||||||
|
Generate Key
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'preshared'"
|
||||||
|
ng-click="showGenerate()">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'automatic'"
|
||||||
|
ng-click="startApproval()">
|
||||||
|
Start Approval
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary" ng-click="downloadPrivateKey(createdKey)" ng-if="createdKey && isDownloadSupported()">
|
||||||
|
<i class="fa fa-download"></i> Download Private Key
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step == 2">Close</button>
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step != 2">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div><!-- /.modal-content -->
|
||||||
|
</div><!-- /.modal-dialog -->
|
||||||
|
</div><!-- /.modal -->
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -61,6 +61,10 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
|
|
||||||
{'id': 'gitlab-trigger', 'title': 'GitLab Build Triggers', 'condition': function(config) {
|
{'id': 'gitlab-trigger', 'title': 'GitLab Build Triggers', 'condition': function(config) {
|
||||||
return config.FEATURE_GITLAB_BUILD;
|
return config.FEATURE_GITLAB_BUILD;
|
||||||
|
}},
|
||||||
|
|
||||||
|
{'id': 'security-scanner', 'title': 'Quay Security Scanner', 'condition': function(config) {
|
||||||
|
return config.FEATURE_SECURITY_SCANNER;
|
||||||
}}
|
}}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1029,6 +1033,87 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
return directiveDefinitionObject;
|
return directiveDefinitionObject;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
.directive('configServiceKeyField', function (ApiService) {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/config/config-service-key-field.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: false,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'serviceName': '@serviceName',
|
||||||
|
},
|
||||||
|
controller: function($scope, $element) {
|
||||||
|
$scope.foundKeys = [];
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.loadError = false;
|
||||||
|
$scope.hasValidKey = false;
|
||||||
|
$scope.hasValidKeyStr = null;
|
||||||
|
|
||||||
|
$scope.updateKeys = function() {
|
||||||
|
$scope.foundKeys = [];
|
||||||
|
$scope.loading = true;
|
||||||
|
|
||||||
|
ApiService.listServiceKeys().then(function(resp) {
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.loadError = false;
|
||||||
|
|
||||||
|
resp['keys'].forEach(function(key) {
|
||||||
|
if (key['service'] == $scope.serviceName) {
|
||||||
|
$scope.foundKeys.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.hasValidKey = checkValidKey($scope.foundKeys);
|
||||||
|
$scope.hasValidKeyStr = $scope.hasValidKey ? 'true' : '';
|
||||||
|
}, function() {
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.loadError = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform initial loading of the keys.
|
||||||
|
$scope.updateKeys();
|
||||||
|
|
||||||
|
$scope.isKeyExpired = function(key) {
|
||||||
|
if (key.expiration_date != null) {
|
||||||
|
var expiration_date = moment(key.expiration_date);
|
||||||
|
return moment().isAfter(expiration_date);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.showRequestServiceKey = function() {
|
||||||
|
$scope.requestKeyInfo = {
|
||||||
|
'service': $scope.serviceName
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.handleKeyCreated = function() {
|
||||||
|
$scope.updateKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
var checkValidKey = function(keys) {
|
||||||
|
for (var i = 0; i < keys.length; ++i) {
|
||||||
|
var key = keys[i];
|
||||||
|
if (!key.approval) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope.isKeyExpired(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
})
|
||||||
|
|
||||||
.directive('configStringField', function () {
|
.directive('configStringField', function () {
|
||||||
var directiveDefinitionObject = {
|
var directiveDefinitionObject = {
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
|
120
static/js/directives/ui/request-service-key-dialog.js
Normal file
120
static/js/directives/ui/request-service-key-dialog.js
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* An element which displays a dialog for requesting or creating a service key.
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('requestServiceKeyDialog', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/request-service-key-dialog.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'requestKeyInfo': '=requestKeyInfo',
|
||||||
|
'keyCreated': '&keyCreated'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element, $timeout, ApiService) {
|
||||||
|
var handleNewKey = function(key) {
|
||||||
|
var data = {
|
||||||
|
'notes': 'Approved during setup of service ' + key.service
|
||||||
|
};
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'kid': key.kid
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.approveServiceKey(data, params).then(function(resp) {
|
||||||
|
$scope.keyCreated({'key': key});
|
||||||
|
$scope.step = 2;
|
||||||
|
}, ApiService.errorDisplay('Could not approve service key'));
|
||||||
|
};
|
||||||
|
|
||||||
|
var checkKeys = function() {
|
||||||
|
var isShown = ($element.find('.modal').data('bs.modal') || {}).isShown;
|
||||||
|
if (!isShown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: filter by service.
|
||||||
|
ApiService.listServiceKeys().then(function(resp) {
|
||||||
|
var keys = resp['keys'];
|
||||||
|
for (var i = 0; i < keys.length; ++i) {
|
||||||
|
var key = keys[i];
|
||||||
|
if (key.service == $scope.requestKeyInfo.service && !key.approval && key.rotation_duration) {
|
||||||
|
handleNewKey(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeout(checkKeys, 1000);
|
||||||
|
}, ApiService.errorDisplay('Could not list service keys'));
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.show = function() {
|
||||||
|
$scope.working = false;
|
||||||
|
$scope.step = 0;
|
||||||
|
$scope.requestKind = null;
|
||||||
|
$scope.preshared = {
|
||||||
|
'name': $scope.requestKeyInfo.service + ' Service Key',
|
||||||
|
'notes': 'Created during setup for service `' + $scope.requestKeyInfo.service + '`'
|
||||||
|
};
|
||||||
|
|
||||||
|
$element.find('.modal').modal({});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.hide = function() {
|
||||||
|
$scope.loading = false;
|
||||||
|
$element.find('.modal').modal('hide');
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.showGenerate = function() {
|
||||||
|
$scope.step = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.startApproval = function() {
|
||||||
|
$scope.step = 1;
|
||||||
|
checkKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.isDownloadSupported = function() {
|
||||||
|
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
|
||||||
|
if (isSafari) {
|
||||||
|
// Doesn't work properly in Safari, sadly.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { return !!new Blob(); } catch(e) {}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.downloadPrivateKey = function(key) {
|
||||||
|
var blob = new Blob([key.private_key]);
|
||||||
|
saveAs(blob, key.service + '.pem');
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.createPresharedKey = function() {
|
||||||
|
$scope.working = true;
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
'name': $scope.preshared.name,
|
||||||
|
'service': $scope.requestKeyInfo.service,
|
||||||
|
'expiration': $scope.preshared.expiration || null,
|
||||||
|
'notes': $scope.preshared.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.createServiceKey(data).then(function(resp) {
|
||||||
|
$scope.working = false;
|
||||||
|
$scope.step = 2;
|
||||||
|
$scope.createdKey = resp;
|
||||||
|
$scope.keyCreated({'key': resp});
|
||||||
|
}, ApiService.errorDisplay('Could not create service key'));
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.$watch('requestKeyInfo', function(info) {
|
||||||
|
if (info && info.service) {
|
||||||
|
$scope.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -1,13 +1,12 @@
|
||||||
(function() {
|
(function() {
|
||||||
/**
|
/**
|
||||||
* The Setup page provides a nice GUI walkthrough experience for setting up the Enterprise
|
* The Setup page provides a nice GUI walkthrough experience for setting up Quay Enterprise.
|
||||||
* Registry.
|
|
||||||
*/
|
*/
|
||||||
angular.module('quayPages').config(['pages', function(pages) {
|
angular.module('quayPages').config(['pages', function(pages) {
|
||||||
pages.create('setup', 'setup.html', SetupCtrl,
|
pages.create('setup', 'setup.html', SetupCtrl,
|
||||||
{
|
{
|
||||||
'newLayout': true,
|
'newLayout': true,
|
||||||
'title': 'Enterprise Registry Setup'
|
'title': 'Quay Enterprise Setup'
|
||||||
})
|
})
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
/**
|
/**
|
||||||
* The superuser admin page provides a new management UI for the Enterprise Registry.
|
* The superuser admin page provides a new management UI for Quay Enterprise.
|
||||||
*/
|
*/
|
||||||
angular.module('quayPages').config(['pages', function(pages) {
|
angular.module('quayPages').config(['pages', function(pages) {
|
||||||
pages.create('superuser', 'super-user.html', SuperuserCtrl,
|
pages.create('superuser', 'super-user.html', SuperuserCtrl,
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="page-content" quay-show="Features.SUPER_USERS && currentStep == States.CONFIG">
|
<div class="page-content" quay-show="Features.SUPER_USERS && currentStep == States.CONFIG">
|
||||||
<div class="cor-title">
|
<div class="cor-title">
|
||||||
<span class="cor-title-link"></span>
|
<span class="cor-title-link"></span>
|
||||||
<span class="cor-title-content">Enterprise Registry Setup</span>
|
<span class="cor-title-content">Quay Enterprise Setup</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cor-tab-panel" style="padding: 20px;">
|
<div class="cor-tab-panel" style="padding: 20px;">
|
||||||
|
|
|
@ -3546,7 +3546,7 @@ class TestRepositoryImageSecurity(ApiTestCase):
|
||||||
|
|
||||||
# Mark the layer as indexed.
|
# Mark the layer as indexed.
|
||||||
layer.security_indexed = True
|
layer.security_indexed = True
|
||||||
layer.security_indexed_engine = app.config['SECURITY_SCANNER']['ENGINE_VERSION_TARGET']
|
layer.security_indexed_engine = app.config['SECURITY_SCANNER_ENGINE_VERSION_TARGET']
|
||||||
layer.save()
|
layer.save()
|
||||||
|
|
||||||
# Grab the security info again.
|
# Grab the security info again.
|
||||||
|
|
|
@ -122,7 +122,7 @@ class TestSecurityScanner(unittest.TestCase):
|
||||||
self.ctx = app.test_request_context()
|
self.ctx = app.test_request_context()
|
||||||
self.ctx.__enter__()
|
self.ctx.__enter__()
|
||||||
|
|
||||||
self.api = SecurityScannerAPI(app.config, config_provider, storage)
|
self.api = SecurityScannerAPI(app.config, storage)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
storage.put_content(['local_us'], 'supports_direct_download', 'false')
|
storage.put_content(['local_us'], 'supports_direct_download', 'false')
|
||||||
|
|
|
@ -61,9 +61,7 @@ class TestConfig(DefaultConfig):
|
||||||
|
|
||||||
FEATURE_SECURITY_SCANNER = True
|
FEATURE_SECURITY_SCANNER = True
|
||||||
FEATURE_SECURITY_NOTIFICATIONS = True
|
FEATURE_SECURITY_NOTIFICATIONS = True
|
||||||
SECURITY_SCANNER = {
|
SECURITY_SCANNER_ENDPOINT = 'http://mockclairservice/'
|
||||||
'ENDPOINT': 'http://mockclairservice/',
|
SECURITY_SCANNER_API_VERSION = 'v1'
|
||||||
'API_VERSION': 'v1',
|
SECURITY_SCANNER_ENGINE_VERSION_TARGET = 1
|
||||||
'ENGINE_VERSION_TARGET': 1,
|
SECURITY_SCANNER_API_TIMEOUT_SECONDS = 1
|
||||||
'API_TIMEOUT_SECONDS': 1
|
|
||||||
}
|
|
||||||
|
|
|
@ -30,10 +30,20 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
|
||||||
'signing-public.gpg')
|
'signing-public.gpg')
|
||||||
config_obj['SIGNING_ENGINE'] = config_obj.get('SIGNING_ENGINE', 'gpg2')
|
config_obj['SIGNING_ENGINE'] = config_obj.get('SIGNING_ENGINE', 'gpg2')
|
||||||
|
|
||||||
|
# Default security scanner config.
|
||||||
|
config_obj['FEATURE_SECURITY_NOTIFICATIONS'] = config_obj.get(
|
||||||
|
'FEATURE_SECURITY_NOTIFICATIONS', True)
|
||||||
|
|
||||||
|
config_obj['FEATURE_SECURITY_SCANNER'] = config_obj.get(
|
||||||
|
'FEATURE_SECURITY_SCANNER', False)
|
||||||
|
|
||||||
|
config_obj['SECURITY_SCANNER_ISSUER_NAME'] = config_obj.get(
|
||||||
|
'SECURITY_SCANNER_ISSUER_NAME', 'security_scanner')
|
||||||
|
|
||||||
# Default mail setings.
|
# Default mail setings.
|
||||||
config_obj['MAIL_USE_TLS'] = True
|
config_obj['MAIL_USE_TLS'] = config_obj.get('MAIL_USE_TLS', True)
|
||||||
config_obj['MAIL_PORT'] = 587
|
config_obj['MAIL_PORT'] = config_obj.get('MAIL_PORT', 587)
|
||||||
config_obj['MAIL_DEFAULT_SENDER'] = 'support@quay.io'
|
config_obj['MAIL_DEFAULT_SENDER'] = config_obj.get('MAIL_DEFAULT_SENDER', 'support@quay.io')
|
||||||
|
|
||||||
# Default auth type.
|
# Default auth type.
|
||||||
if not 'AUTHENTICATION_TYPE' in config_obj:
|
if not 'AUTHENTICATION_TYPE' in config_obj:
|
||||||
|
@ -60,5 +70,5 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
|
||||||
|
|
||||||
# Misc configuration.
|
# Misc configuration.
|
||||||
config_obj['PREFERRED_URL_SCHEME'] = config_obj.get('PREFERRED_URL_SCHEME', 'http')
|
config_obj['PREFERRED_URL_SCHEME'] = config_obj.get('PREFERRED_URL_SCHEME', 'http')
|
||||||
config_obj['ENTERPRISE_LOGO_URL'] = config_obj.get('ENTERPRISE_LOGO_URL',
|
config_obj['ENTERPRISE_LOGO_URL'] = config_obj.get(
|
||||||
'/static/img/quay-logo.png')
|
'ENTERPRISE_LOGO_URL', '/static/img/QuayEnterprise_horizontal_color.svg')
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import redis
|
import redis
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import ldap
|
import ldap
|
||||||
import peewee
|
import peewee
|
||||||
import OpenSSL
|
import OpenSSL
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from StringIO import StringIO
|
from StringIO import StringIO
|
||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
|
@ -14,12 +13,14 @@ from data.users.externalldap import LDAPConnection, LDAPUsers
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.ext.mail import Mail, Message
|
from flask.ext.mail import Mail, Message
|
||||||
from data.database import validate_database_url, User
|
from data.database import validate_database_url
|
||||||
from storage import get_storage_driver
|
from storage import get_storage_driver
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig
|
from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig
|
||||||
from bitbucket import BitBucket
|
from bitbucket import BitBucket
|
||||||
from util.security.signing import SIGNING_ENGINES
|
from util.security.signing import SIGNING_ENGINES
|
||||||
|
from util.secscan.api import SecurityScannerAPI
|
||||||
|
from boot import setup_jwt_proxy
|
||||||
|
|
||||||
from app import app, config_provider, get_app_url, OVERRIDE_CONFIG_DIRECTORY
|
from app import app, config_provider, get_app_url, OVERRIDE_CONFIG_DIRECTORY
|
||||||
|
|
||||||
|
@ -424,6 +425,23 @@ def _validate_signer(config, _):
|
||||||
engine.detached_sign(StringIO('test string'))
|
engine.detached_sign(StringIO('test string'))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_security_scanner(config, _):
|
||||||
|
""" Validates the configuration for talking to a Quay Security Scanner. """
|
||||||
|
# Generate a temporary Quay key to use for signing the outgoing requests.
|
||||||
|
setup_jwt_proxy()
|
||||||
|
|
||||||
|
# Wait a few seconds for the JWT proxy to startup.
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Make a ping request to the security service.
|
||||||
|
client = app.config['HTTPCLIENT']
|
||||||
|
api = SecurityScannerAPI(config, None, client=client, skip_validation=True)
|
||||||
|
response = api.ping()
|
||||||
|
if response.status_code != 200:
|
||||||
|
message = 'Expected 200 status code, got %s: %s' % (response.status_code, response.text)
|
||||||
|
raise Exception('Could not ping security scanner: %s' % message)
|
||||||
|
|
||||||
|
|
||||||
_VALIDATORS = {
|
_VALIDATORS = {
|
||||||
'database': _validate_database,
|
'database': _validate_database,
|
||||||
'redis': _validate_redis,
|
'redis': _validate_redis,
|
||||||
|
@ -439,4 +457,5 @@ _VALIDATORS = {
|
||||||
'jwt': _validate_jwt,
|
'jwt': _validate_jwt,
|
||||||
'keystone': _validate_keystone,
|
'keystone': _validate_keystone,
|
||||||
'signer': _validate_signer,
|
'signer': _validate_signer,
|
||||||
|
'security-scanner': _validate_security_scanner,
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,8 @@ logger = logging.getLogger(__name__)
|
||||||
class LayerAnalyzer(object):
|
class LayerAnalyzer(object):
|
||||||
""" Helper class to perform analysis of a layer via the security scanner. """
|
""" Helper class to perform analysis of a layer via the security scanner. """
|
||||||
def __init__(self, config, api):
|
def __init__(self, config, api):
|
||||||
secscan_config = config.get('SECURITY_SCANNER')
|
|
||||||
|
|
||||||
self._api = api
|
self._api = api
|
||||||
self._target_version = secscan_config['ENGINE_VERSION_TARGET']
|
self._target_version = config.get('SECURITY_SCANNER_ENGINE_VERSION_TARGET', 2)
|
||||||
|
|
||||||
|
|
||||||
def analyze_recursively(self, layer):
|
def analyze_recursively(self, layer):
|
||||||
|
@ -62,7 +60,6 @@ class LayerAnalyzer(object):
|
||||||
- The second one is set to False when another worker pre-empted the candidate's analysis
|
- The second one is set to False when another worker pre-empted the candidate's analysis
|
||||||
for us.
|
for us.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If the parent couldn't be analyzed with the target version or higher, we can't analyze
|
# If the parent couldn't be analyzed with the target version or higher, we can't analyze
|
||||||
# this image. Mark it as failed with the current target version.
|
# this image. Mark it as failed with the current target version.
|
||||||
if (layer.parent_id and not layer.parent.security_indexed and
|
if (layer.parent_id and not layer.parent.security_indexed and
|
||||||
|
|
|
@ -21,26 +21,23 @@ _API_METHOD_INSERT = 'layers'
|
||||||
_API_METHOD_GET_LAYER = 'layers/%s'
|
_API_METHOD_GET_LAYER = 'layers/%s'
|
||||||
_API_METHOD_MARK_NOTIFICATION_READ = 'notifications/%s'
|
_API_METHOD_MARK_NOTIFICATION_READ = 'notifications/%s'
|
||||||
_API_METHOD_GET_NOTIFICATION = 'notifications/%s'
|
_API_METHOD_GET_NOTIFICATION = 'notifications/%s'
|
||||||
|
_API_METHOD_PING = 'metrics'
|
||||||
|
|
||||||
|
|
||||||
class SecurityScannerAPI(object):
|
class SecurityScannerAPI(object):
|
||||||
""" Helper class for talking to the Security Scan service (Clair). """
|
""" Helper class for talking to the Security Scan service (Clair). """
|
||||||
def __init__(self, config, config_provider, storage):
|
def __init__(self, config, storage, client=None, skip_validation=False):
|
||||||
self.config = config
|
if not skip_validation:
|
||||||
self.config_provider = config_provider
|
config_validator = SecurityConfigValidator(config)
|
||||||
|
if not config_validator.valid():
|
||||||
|
logger.warning('Invalid config provided to SecurityScannerAPI')
|
||||||
|
return
|
||||||
|
|
||||||
|
self._config = config
|
||||||
|
self._client = client or config['HTTPCLIENT']
|
||||||
self._storage = storage
|
self._storage = storage
|
||||||
self._security_config = None
|
|
||||||
|
|
||||||
config_validator = SecurityConfigValidator(config, config_provider)
|
|
||||||
if not config_validator.valid():
|
|
||||||
logger.warning('Invalid config provided to SecurityScannerAPI')
|
|
||||||
return
|
|
||||||
|
|
||||||
self._default_storage_locations = config['DISTRIBUTED_STORAGE_PREFERENCE']
|
self._default_storage_locations = config['DISTRIBUTED_STORAGE_PREFERENCE']
|
||||||
|
self._target_version = config.get('SECURITY_SCANNER_ENGINE_VERSION_TARGET', 2)
|
||||||
self._security_config = config.get('SECURITY_SCANNER')
|
|
||||||
self._target_version = self._security_config['ENGINE_VERSION_TARGET']
|
|
||||||
|
|
||||||
|
|
||||||
def _get_image_url(self, image):
|
def _get_image_url(self, image):
|
||||||
|
@ -62,7 +59,7 @@ class SecurityScannerAPI(object):
|
||||||
if uri is None:
|
if uri is None:
|
||||||
# Handle local storage.
|
# Handle local storage.
|
||||||
local_storage_enabled = False
|
local_storage_enabled = False
|
||||||
for storage_type, _ in self.config.get('DISTRIBUTED_STORAGE_CONFIG', {}).values():
|
for storage_type, _ in self._config.get('DISTRIBUTED_STORAGE_CONFIG', {}).values():
|
||||||
if storage_type == 'LocalStorage':
|
if storage_type == 'LocalStorage':
|
||||||
local_storage_enabled = True
|
local_storage_enabled = True
|
||||||
|
|
||||||
|
@ -99,6 +96,23 @@ class SecurityScannerAPI(object):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
""" Calls GET on the metrics endpoint of the security scanner to ensure it is running
|
||||||
|
and properly configured. Returns the HTTP response.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._call('GET', _API_METHOD_PING)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.exception('Timeout when trying to connect to security scanner endpoint')
|
||||||
|
raise Exception('Timeout when trying to connect to security scanner endpoint')
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.exception('Connection error when trying to connect to security scanner endpoint')
|
||||||
|
raise Exception('Connection error when trying to connect to security scanner endpoint')
|
||||||
|
except (requests.exceptions.RequestException, ValueError):
|
||||||
|
logger.exception('Exception when trying to connect to security scanner endpoint')
|
||||||
|
raise Exception('Exception when trying to connect to security scanner endpoint')
|
||||||
|
|
||||||
|
|
||||||
def analyze_layer(self, layer):
|
def analyze_layer(self, layer):
|
||||||
""" Posts the given layer to the security scanner for analysis, blocking until complete.
|
""" Posts the given layer to the security scanner for analysis, blocking until complete.
|
||||||
Returns a tuple containing the analysis version (on success, None on failure) and
|
Returns a tuple containing the analysis version (on success, None on failure) and
|
||||||
|
@ -122,6 +136,7 @@ class SecurityScannerAPI(object):
|
||||||
logger.exception('Failed to post layer data response for %s', layer.id)
|
logger.exception('Failed to post layer data response for %s', layer.id)
|
||||||
return None, False
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
# Handle any errors from the security scanner.
|
# Handle any errors from the security scanner.
|
||||||
if response.status_code != 201:
|
if response.status_code != 201:
|
||||||
message = json_response.get('Error').get('Message', '')
|
message = json_response.get('Error').get('Message', '')
|
||||||
|
@ -235,25 +250,23 @@ class SecurityScannerAPI(object):
|
||||||
This function disconnects from the database while awaiting a response
|
This function disconnects from the database while awaiting a response
|
||||||
from the API server.
|
from the API server.
|
||||||
"""
|
"""
|
||||||
security_config = self._security_config
|
if self._config is None:
|
||||||
if security_config is None:
|
|
||||||
raise Exception('Cannot call unconfigured security system')
|
raise Exception('Cannot call unconfigured security system')
|
||||||
|
|
||||||
client = self.config['HTTPCLIENT']
|
client = self._client
|
||||||
headers = {'Connection': 'close'}
|
headers = {'Connection': 'close'}
|
||||||
|
|
||||||
timeout = security_config['API_TIMEOUT_SECONDS']
|
timeout = self._config.get('SECURITY_SCANNER_API_TIMEOUT_SECONDS', 10)
|
||||||
endpoint = security_config['ENDPOINT']
|
endpoint = self._config['SECURITY_SCANNER_ENDPOINT']
|
||||||
if method != 'GET':
|
if method != 'GET':
|
||||||
timeout = security_config.get('API_BATCH_TIMEOUT_SECONDS', timeout)
|
timeout = self._config.get('SECURITY_SCANNER_API_BATCH_TIMEOUT_SECONDS', timeout)
|
||||||
endpoint = security_config.get('ENDPOINT_BATCH', endpoint)
|
endpoint = self._config.get('SECURITY_SCANNER_ENDPOINT_BATCH') or endpoint
|
||||||
|
|
||||||
api_url = urljoin(endpoint, '/' + security_config['API_VERSION']) + '/'
|
api_url = urljoin(endpoint, '/' + self._config.get('SECURITY_SCANNER_API_VERSION', 'v1')) + '/'
|
||||||
url = urljoin(api_url, relative_url)
|
url = urljoin(api_url, relative_url)
|
||||||
signer_proxy_url = self.config.get('JWTPROXY_SIGNER', 'localhost:8080')
|
signer_proxy_url = self._config.get('JWTPROXY_SIGNER', 'localhost:8080')
|
||||||
|
|
||||||
|
with CloseForLongOperation(self._config):
|
||||||
with CloseForLongOperation(self.config):
|
|
||||||
logger.debug('%sing security URL %s', method.upper(), url)
|
logger.debug('%sing security URL %s', method.upper(), url)
|
||||||
return client.request(method, url, json=body, params=params, timeout=timeout,
|
return client.request(method, url, json=body, params=params, timeout=timeout,
|
||||||
verify='/conf/mitm.cert', headers=headers,
|
verify='/conf/mitm.cert', headers=headers,
|
||||||
|
|
|
@ -6,55 +6,23 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SecurityConfigValidator(object):
|
class SecurityConfigValidator(object):
|
||||||
""" Helper class for validating the security scanner configuration. """
|
""" Helper class for validating the security scanner configuration. """
|
||||||
def __init__(self, config, config_provider):
|
def __init__(self, config):
|
||||||
self._config_provider = config_provider
|
|
||||||
|
|
||||||
if not features.SECURITY_SCANNER:
|
if not features.SECURITY_SCANNER:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._security_config = config['SECURITY_SCANNER']
|
self._config = config
|
||||||
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):
|
def valid(self):
|
||||||
if not features.SECURITY_SCANNER:
|
if not features.SECURITY_SCANNER:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not self._security_config:
|
if self._config.get('SECURITY_SCANNER_ENDPOINT') is None:
|
||||||
logger.debug('Missing SECURITY_SCANNER block in configuration')
|
logger.debug('Missing SECURITY_SCANNER_ENDPOINT configuration')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not 'ENDPOINT' in self._security_config:
|
endpoint = self._config.get('SECURITY_SCANNER_ENDPOINT')
|
||||||
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://'):
|
if not endpoint.startswith('http://') and not endpoint.startswith('https://'):
|
||||||
logger.debug('ENDPOINT field in SECURITY_SCANNER configuration must start with http or https')
|
logger.debug('SECURITY_SCANNER_ENDPOINT configuration must start with http or https')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
Reference in a new issue