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

Commit

Permalink
Basic support for security scanning
Browse files Browse the repository at this point in the history
Scanners supported: CoreOS Clair, zypper-docker (experimental, based on
an unmerged branch). I've also included a `dummy` scanner, for
development purposes.

Signed-off-by: Miquel Sabaté Solà <msabate@suse.com>
Signed-off-by: Vítor Avelino <vavelino@suse.com>
  • Loading branch information
mssola authored and vitoravelino committed Jul 18, 2017
1 parent c7c2bf0 commit 4cd875c
Show file tree
Hide file tree
Showing 14 changed files with 421 additions and 2 deletions.
28 changes: 28 additions & 0 deletions app/assets/javascripts/utils/set_typeahead.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// setTypeahead sets up the typeahead plugin for the given url. This function
// also assumes that there is an element with the following selector
// ".remote .typeahead".
export const setTypeahead = function (url) {
var bloodhound;

$('.remote .typeahead').typeahead('destroy');
bloodhound = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('name'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
cache: false,
url: url,
wildcard: '%QUERY',
},
});

bloodhound.initialize();

$('.remote .typeahead').typeahead({ highlight: true }, {
displayKey: 'name',
source: bloodhound.ttAdapter(),
});
};

export default {
setTypeahead,
};
1 change: 1 addition & 0 deletions app/views/repositories/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
title="Delete image"]
.fa.fa-trash.space-xs-sides
' Delete image

.panel.panel-default
.panel-heading
.row
Expand Down
36 changes: 36 additions & 0 deletions bin/security.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
require "portus/security"

if ARGV.size != 2
puts "Usage: rails runner bin/security.rb <image> <tag>"
exit 1
end

image, tag = ARGV
sec = ::Portus::Security.new(image, tag)
vulns = sec.vulnerabilities

vulns.each do |name, result|
hsh = {}

n = name.to_s.capitalize
print "#{n}\n" + ("=" * n.size) + "\n"

if result.nil?
print "\nWork in progress...\n"
next
end

result.each do |v|
hsh[v["Severity"]] = 0 unless hsh.include?(v["Severity"])
hsh[v["Severity"]] += 1

puts "#{v["Name"]}: #{v["Severity"]}"
puts ""
puts v["Link"].to_s
puts "---------------"
end

print "\nFound #{result.size} vulnerabilities:\n\n"
hsh.each { |k, v| puts "#{k}: #{v}" }
puts ""
end
16 changes: 16 additions & 0 deletions config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,19 @@ user_permission:
# is disabled, only an admin will be able to do this. This defaults to true.
manage_namespace:
enabled: true

# Security scanner support. Add the server location for each driver in order to
# enable it. If no drivers have been enabled, then this feature is skipped
# altogether. Enabling multiple drivers will simply aggregate the information
# provided by each driver.
security:
# CoreOS Clair support (https://github.com/coreos/clair).
clair:
server: ""
# zypper-docker can be run as a server with its `serve` command. This backend
# fetches the information as given by zypper-docker.
zypper:
server: ""
# This backend is only used for testing purposes, don't use it.
dummy:
server: ""
47 changes: 47 additions & 0 deletions doc/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# API

This is a work-in-progress document that tries to achieve what I'll be doing for
this hackweek, and what it should be done in the near future regarding the API.

## Hackweek

### Authentication

There should be a way to have the authentication part working as
expected. It should be as simple as possible.

### Vulnerabilities

This will be part of the "Repositories & tags" endpoints. There should be a way
to fetch the list of vulnerabilities for the given repo + tag. Suggested paths:

* `/repositories/<image>/vulnerabilities`
* `/repositories/<image>/tags/<tag>/vulnerabilities`

Note that the second form should be clarified once I get the repositories#show
page straight. So, for now I'll only implement the first one.

## Near future

Most of these routes already exist, but they should be implemented for the new
scheme as well.

### Administration

- Create & update registries.
- Fetch activities.

### Application tokens

Not sure if this conflicts with the authentication part. If it doesn't, then the
`create` and the `destroy` actions should be re-implemented so it responds back
with JSON data.

### Namespaces & teams

All actions for creating, updating and destroying teams and namespaces.

### Repositories and tags

List and show actions should be implemented (along with the already existing
vulnerabilities endpoints).
11 changes: 9 additions & 2 deletions lib/portus/http_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class NotFoundError < RuntimeError; end
# Raised if this client does not have the credentials to perform an API call.
class CredentialsMissingError < RuntimeError; end

# Returns an URI object and a request object for the given path & method
# pair.
def get_request(path, method)
uri = URI.join(@base_url, path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
[uri, req]
end

# This is the general method to perform an HTTP request to an endpoint
# provided by the registry. The first parameter is the URI of the endpoint
# itself. The second parameter is the HTTP method in downcase (e.g. "post").
Expand All @@ -29,8 +37,7 @@ class CredentialsMissingError < RuntimeError; end
# when calling the given path, it should get an authorization token
# automatically and try again.
def perform_request(path, method = "get", request_auth_token = true)
uri = URI.join(@base_url, path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
uri, req = get_request(path, method)

# So we deal with compatibility issues in distribution 2.3 and later.
# See: https://github.com/docker/distribution/blob/master/docs/compatibility.md#content-addressable-storage-cas
Expand Down
3 changes: 3 additions & 0 deletions lib/portus/registry_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ module Portus
# it also implements some handy methods on top of some of these endpoints (e.g.
# the `manifest` method for the Manifest API endpoints).
class RegistryClient
attr_accessor :token
attr_reader :base_url

include HttpHelpers

# Exception being raised when we get an error from the Registry API that we
Expand Down
43 changes: 43 additions & 0 deletions lib/portus/security.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require "portus/security_backends/clair"
require "portus/security_backends/dummy"
require "portus/security_backends/zypper"

module Portus
class Security
BACKENDS = [
::Portus::SecurityBackend::Clair,
::Portus::SecurityBackend::Dummy,
::Portus::SecurityBackend::Zypper
].freeze

def initialize(repo, tag)
@repo = repo
@tag = tag
@backends = []

BACKENDS.each { |b| @backends << b.new(repo, tag) if b.enabled? }
end

# Returns a hash with the results from all the backends. The results are a
# list of hashes.
# TODO: document format
def vulnerabilities
# First get all the layers composing the given image.
client = Registry.get.client
manifest = client.manifest(@repo, @tag)

params = {
layers: manifest.last["layers"].map { |l| l["digest"] },
token: client.token,
registry_url: client.base_url
}

res = {}
@backends.each do |b|
name = b.class.name.to_s.demodulize.downcase.to_sym
res[name] = b.vulnerabilities(params)
end
res
end
end
end
29 changes: 29 additions & 0 deletions lib/portus/security_backends/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require "portus/http_helpers"

module Portus
module SecurityBackend
# Base implements basic functionality that each security backend should
# have. All security backends should subclass this one.
class Base
include ::Portus::HttpHelpers

def initialize(repo, tag)
@repo = repo
@tag = tag
@base_url = self.class.configuration["server"]
end

# Returns true if the given backend has been enabled, false otherwise.
def self.enabled?
cfg = configuration
!cfg["server"].blank?
end

# Returns the configuration of the given backend.
def self.configuration
n = name.to_s.demodulize.downcase
APP_CONFIG["security"][n]
end
end
end
end
119 changes: 119 additions & 0 deletions lib/portus/security_backends/clair.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
require "portus/security_backends/base"

# Docker images contain quite some empty blobs, and trying to upload them will
# fail.
EMPTY_LAYER_SHA = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4".freeze

module Portus
module SecurityBackend
# Clair implements all security-related methods by using CoreOS' Clair
# (https://github.com/coreos/clair)
class Clair < ::Portus::SecurityBackend::Base
# Returns the vulnerabilities that can be found for the given layers. In
# order to do so, this method needs an authentication token that will be
# used to post this images into Clair. Moreover, this method also needs
# the URL of the registry, since it needs to pass this information to
# Clair.
def vulnerabilities(params)
@token = params[:token]
@registry_url = params[:registry_url]

# Filter out empty layers.
@layers = params[:layers].reject { |digest| digest == EMPTY_LAYER_SHA }

# We first post everything in reverse order, so parent layers are
# available when inspecting vulnerabilities.
@layers.reverse.each_index { |k| post_layer(k) }

# Finally, according to Clair's documentation, requesting
# vulnerabilities from the last child will give you all of them.
layer_vulnerabilities(@layers.last)
end

protected

# Returns an array with all the vulnerabilities found by Clair for the
# given digest.
def layer_vulnerabilities(digest)
layer = fetch_layer(digest)
return [] if layer.nil?

res = []
known = []
layer["Features"].each do |f|
vulns = f["Vulnerabilities"]
next if vulns.nil?

vulns.each do |v|
if v && v["Name"] && !known.include?(v["Name"])
known << v["Name"]
res << v
end
end
end
res
end

# Fetches the layer information from Clair for the given digest as a Hash.
# If nothing could be extracted, then nil is returned.
def fetch_layer(digest)
# Now we fetch the vulnerabilities discovered by clair on that layer.
uri, req = get_request("/v1/layers/#{digest}?features=false&vulnerabilities=true", "get")
res = get_response_token(uri, req)

# Parse the given response and return the result.
msg = JSON.parse(res.body)
if res.code.to_i == 200
msg["Layer"]
else
msg = error_message(msg)
Rails.logger.tagged("clair.get") { Rails.logger.debug "Error for '#{digest}': #{msg}" }
nil
end
end

# Post the layer pointed by the given index to Clair.
def post_layer(index)
parent = index > 0 ? @layers.fetch(index - 1) : ""
digest = @layers.fetch(index)

uri, req = get_request("/v1/layers", "post")
req.body = layer_body(digest, parent).to_json

res = get_response_token(uri, req)
if res.code.to_i != 200 && res.code.to_i != 201
msg = error_message(JSON.parse(res.body))
Rails.logger.tagged("clair.post") do
Rails.logger.debug "Could not post '#{digest}': #{msg}"
end
end
end

# Returns a hash that has to be used as the body of a POST request. This
# method requires the digest of the layer to be pushed, and the digest of
# the parent layer. If this layer has no parent, then it should be an
# empty string.
def layer_body(digest, parent)
path = URI.join(@registry_url.to_s, "/v2/#{@repo}/blobs/#{digest}").to_s

{
"Layer" => {
"Name" => digest,
"NamespaceName" => "",
"Path" => path,
"Headers" => { "Authorization" => "Bearer #{@token}" },
"ParentName" => parent,
"Format" => "Docker",
"IndexedByVersion" => 0,
"Features" => []
}
}
end

# Returns a proper error message for the given JSON response.
def error_message(msg)
msg["Error"] && msg["Error"]["Message"] ? msg["Error"]["Message"] : msg
end
end
end
end
26 changes: 26 additions & 0 deletions lib/portus/security_backends/dummy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require "portus/security_backends/base"

module Portus
module SecurityBackend
# Dummy implements a backend that simply returns fixture data. This backend
# is meant to be used only for development/testing purposes.
class Dummy < ::Portus::SecurityBackend::Base
# Files stored in `lib/portus/security_backends/fixtures`.
DUMMY_FIXTURE = "dummy.json".freeze

# Whether the response from the `vulnerabilities` method should be as
# "Working in Progress".
WIP = true

# Returns nil if the dummy backend is "working on it", otherwise it
# returns a list of vulnerabilities as specified by the DUMMY_FIXTURE
# constant.
def vulnerabilities(_params)
return nil if WIP

path = Rails.root.join("lib", "portus", "security_backends", "fixtures", DUMMY_FIXTURE)
JSON.parse(File.read(path))
end
end
end
end
Loading

0 comments on commit 4cd875c

Please sign in to comment.