├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── clair_singularity ├── __init__.py ├── clair.py ├── cli.py ├── image.py └── util.py ├── screenshot.png ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_clair.py ├── test_cli.py ├── test_image.py └── test_util.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | workflows: 4 | default: 5 | jobs: 6 | - build-and-test 7 | 8 | jobs: 9 | build-and-test: 10 | machine: 11 | image: ubuntu-2004:current 12 | steps: 13 | - checkout 14 | - run: 15 | name: install deps 16 | command: | 17 | sudo apt-get update 18 | sudo apt-get -y install cryptsetup-bin squashfs-tools 19 | - run: 20 | name: install singularity 21 | command: | 22 | wget https://github.com/sylabs/singularity/releases/download/v3.9.7/singularity-ce_3.9.7-focal_amd64.deb 23 | sudo apt -y install ./singularity-ce_3.9.7-focal_amd64.deb 24 | rm singularity-ce_3.9.7-focal_amd64.deb 25 | - run: 26 | name: install 27 | command: python3 setup.py install --user 28 | - run: 29 | name: start clair 30 | command: | 31 | docker pull arminc/clair-db:2021-06-14 32 | docker run -d --name clair-db arminc/clair-db:2021-06-14 33 | sleep 5 34 | docker pull arminc/clair-local-scan:v2.1.7_5125fde67edee46cb058a3feee7164af9645e07d 35 | docker run -p 6060:6060 --link clair-db:postgres -d --name clair arminc/clair-local-scan:v2.1.7_5125fde67edee46cb058a3feee7164af9645e07d 36 | - run: 37 | name: test 38 | command: python3 setup.py test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | dist/ 4 | build/ 5 | *.egg-info/ 6 | 7 | .tox/ 8 | .coverage 9 | 10 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, The University of Texas Southwestern Medical Center, Lyda Hill Department of Bioinformatics 4 | All rights reserved. 5 | Copyright (c) 2021, Sylabs Inc. All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clair-singularity 2 | 3 | [![CircleCI](https://circleci.com/gh/dtrudg/clair-singularity/tree/master.svg?style=svg)](https://circleci.com/gh/dtrudg/clair-singularity/tree/master) 4 | 5 | __Scan [Singularity](http://sylabs.io/singularity/) container images for 6 | security vulnerabilities using [CoreOS 7 | Clair](https://github.com/coreos/clair).__ 8 | 9 | ![screenshot](screenshot.png) 10 | 11 | The [CoreOS Clair vulnerability scanner](https://github.com/coreos/clair) is a 12 | useful tool able to scan docker and other container formats for security 13 | vulnerabilities. It obtains up-to-date lists of vulnerabilities for various 14 | platforms (namespaces) from public databases. 15 | 16 | We can use Clair to scan singularity containers, by exploiting the fact that an 17 | exported .tar.gz of a singularity container image is similar to a single layer 18 | docker image. 19 | 20 | This tool: 21 | 22 | * Exports a singularity image to a temporary .tar.gz file (this will be under 23 | `$TMPDIR`) 24 | * Serves the .tar.gz file via an in-built http server, so the Clair service can 25 | retrieve it 26 | * Calls the Clair API to ingest the .tar.gz file as a layer for analysis 27 | * Calls the Clair API to retireve a vulnerability report for this layer 28 | * Displays a simple text, or full JSON format report 29 | 30 | Based on experiments detailed [in this 31 | Gist](https://gist.github.com/dctrud/479797e5f48cfe39cdb4b50a15e4c567) 32 | 33 | ## IMPORTANT NOTES 34 | 35 | Functionality was last tested using SingularityCE 3.9.7. 36 | 37 | This tool should be considered proof of concept, not heavily tested. Use at your 38 | own risk. 39 | 40 | There is no support yet for SSL client certificates to verify that we are 41 | sending API requests to a trusted Clair instance, or that only a trusted Clair 42 | instance can retrieve images from the inbuilt http server. *This means that this 43 | solution is insecure except with an isolated local install of Clair*. 44 | 45 | ## Requirements 46 | 47 | To use clair-singularity you will need a *Linux* host with: 48 | 49 | * Python 3.5 or greater installed 50 | * SingularityCE 3+ installed (tested with 3.9.7) and the singularity 51 | executable in your `PATH` 52 | * A Clair instance running somewhere, that is able to access the machine you 53 | will run clair-singularity on. It's easiest to accomplish this using docker to 54 | run a local Clair instance as below. 55 | 56 | ## Starting a local Clair instance 57 | 58 | If you have docker available on your local machine, the easiest way to start 59 | scanning your Singularity images is to fire up a Clair instance locally, with 60 | docker. The official Clair docker images are a blank slate, and do not include 61 | any vulnerability information. At startup Clair will have to download 62 | vulnerability information from the internet, which can be quite slow. Images 63 | from github user arminc are available that include pre-seeded databases: 64 | 65 | 66 | 67 | To startup a Clair instance locally using these instances: 68 | 69 | ```bash 70 | docker run -d --name db arminc/clair-db:2022-03-31 71 | docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.1.8_9bca9a9a7bce2fd2e84efcc98ab00c040177e258 72 | ``` 73 | 74 | *Replace the clair-db:2022-03-31 image tag with a later date for newer 75 | vulnerabilities* 76 | 77 | ## Installation 78 | 79 | Clone the git repo, or download and extract the zip then: 80 | 81 | ```bash 82 | python setup.py install 83 | ``` 84 | 85 | ## Usage 86 | 87 | ### Clair on same machine 88 | 89 | To scan a singularity image, using a clair instance running under local docker, 90 | on port 6060: 91 | 92 | ```bash 93 | clair-singularity myimage.sif 94 | ``` 95 | 96 | /If your hostname is not resolvable to a non-localhost IP of your machine, 97 | accessible to docker containers, you must specify the IP with `--bind-ip`/ 98 | 99 | ### Clair on a different machine 100 | 101 | If clair is running on a different machine, you must use the `--clair-uri` 102 | option to specify the base URI to the clair instance, and the `--bind-ip` and/or 103 | `--bind-port` options to specify a public IP and port on this machine, that 104 | clair can access to retrieve images from `clair-singularity`. 105 | 106 | ```bash 107 | clair-singularity \ 108 | --clair-uri http://10.0.1.202:6060 \ 109 | --bind-ip=10.0.1.201 \ 110 | --bind-port=8088 myimage.img 111 | ``` 112 | 113 | ### Full JSON Reports 114 | 115 | By default, clair-singularity gives a simplified text report on STDOUT. To 116 | obtain the full JSON report returned by Clair use the `--jsoon-output` option. 117 | 118 | ```bash 119 | clair-singularity --json-output myimage.img 120 | ``` 121 | -------------------------------------------------------------------------------- /clair_singularity/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.3.0' 2 | -------------------------------------------------------------------------------- /clair_singularity/clair.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pprint 3 | import requests 4 | import sys 5 | import time 6 | 7 | from .util import pretty_json 8 | from texttable import Texttable 9 | 10 | class ClairException(Exception): 11 | pass 12 | 13 | 14 | def check_clair(API_URI, verbose): 15 | """Check Clair is accessible by call to namespaces end point""" 16 | 17 | if verbose: 18 | sys.stderr.write("Checking for Clair v1 API\n") 19 | try: 20 | r = requests.get(API_URI + 'namespaces') 21 | namespace_count = len(r.json()['Namespaces']) 22 | if verbose: 23 | sys.stderr.write("Found Clair server with %d namespaces\n" % namespace_count) 24 | except Exception as e: 25 | raise ClairException("Error - couldn't access Clair v1 API at %s\n%s\n" % (API_URI, e)) 26 | return True 27 | 28 | 29 | def post_layer(API_URI, image_name, image_uri, verbose): 30 | """Register an image .tar.gz with Clair as a parent-less layer""" 31 | 32 | try: 33 | 34 | payload = { 35 | "Layer": {"Name": image_name, 36 | "Path": image_uri, 37 | "Format": "Docker"} 38 | } 39 | 40 | if verbose: 41 | sys.stderr.write(pprint.pformat(payload)) 42 | 43 | time.sleep(1) 44 | 45 | r = requests.post(API_URI + 'layers', json=payload) 46 | 47 | if r.status_code == requests.codes.created: 48 | if verbose: 49 | sys.stderr.write("Image registered as layer with Clair\n") 50 | else: 51 | raise ClairException("Failed registering image with Clair\n %s\n" % pretty_json(r.json())) 52 | 53 | except Exception as e: 54 | raise ClairException("Error - couldn't send image to Clair - %s\n" % (e)) 55 | 56 | 57 | def get_report(API_URI, image_name): 58 | """Retrieve and return the features & vulnerabilities report from Clair""" 59 | 60 | try: 61 | r = requests.get(API_URI + 'layers/' + image_name, params={'features': 'true', 'vulnerabilities': 'true'}) 62 | 63 | if r.status_code == requests.codes.ok: 64 | return r.json() 65 | else: 66 | raise ClairException("Failed retrieving report from Clair\n %s\n" % pretty_json(r.json())) 67 | 68 | except Exception as e: 69 | raise ClairException("Error - couldn't retrieve report from Clair - %s\n" % (e)) 70 | 71 | 72 | def format_report_text(report): 73 | """Format the json into a very simple plain text report of vulnerabilities 74 | per feature""" 75 | 76 | if 'Features' not in report['Layer'].keys(): 77 | print("No features were found in the image - report cannot be generated.\n") 78 | return 79 | 80 | 81 | features = report['Layer']['Features'] 82 | headers = ["Feature", "Version", "Severity", "Identifier", "Description"] 83 | vulns = [headers] 84 | 85 | vulnFeatures = 0 86 | 87 | for feature in features: 88 | if 'Vulnerabilities' in feature: 89 | vulnFeatures+=1 90 | for vuln in feature['Vulnerabilities']: 91 | 92 | vulns.append([ 93 | feature['Name'], 94 | feature['Version'], 95 | vuln['Severity'], 96 | vuln['Name'], 97 | vuln['Link'] + "\n" + vuln['Description'] 98 | ]) 99 | 100 | print("Image contains %d features/packages total.\n" % len(features)) 101 | print("Found %d vulnerabilities in %d features/packages:\n" % (len(vulns)-1, vulnFeatures)) 102 | 103 | if vulnFeatures > 0: 104 | width = 80 105 | try: 106 | width = os.get_terminal_size().columns 107 | except OSError: 108 | pass 109 | 110 | table = Texttable() 111 | table.set_max_width(width) 112 | table.set_cols_align(["l", "l", "c", "l", "l"]) 113 | table.add_rows(vulns) 114 | print(table.draw()) 115 | -------------------------------------------------------------------------------- /clair_singularity/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | from os import path 4 | import shutil 5 | import socket 6 | from multiprocessing import Process 7 | 8 | from . import VERSION 9 | from .clair import check_clair, post_layer, get_report, format_report_text, ClairException 10 | from .util import sha256, wait_net_service, err_and_exit, pretty_json, find_free_port 11 | from .image import check_image, image_to_tgz, http_server, ImageException 12 | 13 | 14 | @click.command() 15 | @click.option('--clair-uri', default="http://localhost:6060", 16 | help='Base URI for your Clair server') 17 | @click.option('--text-output', is_flag=True, help='Report in Text (Default)') 18 | @click.option('--json-output', is_flag=True, help='Report in JSON') 19 | @click.option('--bind-ip', default="", 20 | help='IP address that the HTTP server providing image to Clair should listen on') 21 | @click.option('--bind-port', default=0, 22 | help='Port that the HTTP server providing image to Clair should listen on') 23 | @click.option('--verbose', is_flag=True, help='Show progress messages to STDERR') 24 | @click.version_option(version=VERSION) 25 | @click.argument('image', required=True) 26 | def cli(image, clair_uri, text_output, json_output, bind_ip, bind_port, verbose): 27 | # Try to get host IP that will be accessible to clair in docker 28 | if bind_ip == "": 29 | local_ip = socket.gethostbyname(socket.gethostname()) 30 | if local_ip == "127.0.0.1": 31 | err_and_exit("Local IP resolved to 127.0.0.1. Please use --bind-ip to specify your true IP address, so that the clair scanner can access the SIF image.", 1) 32 | bind_ip = local_ip 33 | 34 | API_URI = clair_uri + '/v1/' 35 | 36 | # Check image exists, and export it to a gzipped tar in a temporary directory 37 | try: 38 | check_image(image) 39 | (tar_dir, tar_file) = image_to_tgz(image, verbose) 40 | except ImageException as e: 41 | err_and_exit(e, 1) 42 | 43 | # Image name for Clair will be the SHA256 of the .tar.gz 44 | image_name = sha256(tar_file) 45 | if verbose: 46 | click.echo("Image has SHA256: %s" % image_name, err=True) 47 | 48 | # Make sure we can talk to Clair OK 49 | try: 50 | check_clair(API_URI, verbose) 51 | except ClairException as e: 52 | err_and_exit(e) 53 | 54 | # Start an HTTP server to serve the .tar.gz from our temporary directory 55 | # so that Clair can retrieve it 56 | if bind_port == 0: 57 | bind_port = find_free_port() 58 | httpd = Process(target=http_server, args=(tar_dir, bind_ip, bind_port, verbose)) 59 | httpd.daemon = True 60 | httpd.start() 61 | # Allow up to 30 seconds for the httpd to start and be answering requests 62 | httpd_ready = wait_net_service(bind_ip, bind_port, 30) 63 | if not httpd_ready: 64 | httpd.terminate() 65 | shutil.rmtree(tar_dir) 66 | err_and_exit("Error: HTTP server did not become ready\n", 1) 67 | 68 | image_uri = 'http://%s:%d/%s' % (bind_ip, bind_port, path.basename(tar_file)) 69 | 70 | # Register the iamge with Clair as a docker layer that has no parent 71 | try: 72 | post_layer(API_URI, image_name, image_uri, verbose) 73 | except ClairException as e: 74 | httpd.terminate() 75 | shutil.rmtree(tar_dir) 76 | err_and_exit(e, 1) 77 | 78 | # Done with the .tar.gz so stop serving it and remove the temp dir 79 | httpd.terminate() 80 | shutil.rmtree(tar_dir) 81 | 82 | # Retrieve the vulnerability report from Clair 83 | report = get_report(API_URI, image_name) 84 | 85 | # Spit out the report on STDOUT 86 | if json_output: 87 | pretty_report = pretty_json(report) 88 | click.echo(pretty_report) 89 | else: 90 | format_report_text(report) 91 | -------------------------------------------------------------------------------- /clair_singularity/image.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import tempfile 4 | from os import path, chdir 5 | 6 | from six.moves import SimpleHTTPServer, socketserver 7 | 8 | 9 | class ImageException(Exception): 10 | pass 11 | 12 | def check_image(image): 13 | """Check if specified image file exists""" 14 | 15 | if not path.isfile(image): 16 | raise ImageException('Error: Singularity image "%s" not found.' % image) 17 | return True 18 | 19 | 20 | def image_to_tgz(image, verbose): 21 | """Export the singularity image to a tar.gz file""" 22 | 23 | sandbox_dir = tempfile.mkdtemp() 24 | tar_dir = tempfile.mkdtemp() 25 | tar_gz_file = path.join(tar_dir, path.basename(image) + '.tar.gz') 26 | 27 | cmd = ['singularity', 'build', '-F', '--fix-perms', '--sandbox', sandbox_dir, image] 28 | 29 | if verbose: 30 | sys.stderr.write("Exporting image to sandbox.\n") 31 | 32 | try: 33 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 34 | except (subprocess.CalledProcessError, OSError) as e: 35 | raise ImageException("Error calling Singularity export to create sandbox\n%s" % e) 36 | 37 | if verbose: 38 | sys.stderr.write(output.decode("utf-8")) 39 | 40 | cmd = ['tar', '--ignore-failed-read', '-C', sandbox_dir, '-zcf', tar_gz_file, '.'] 41 | 42 | if verbose: 43 | sys.stderr.write("Compressing to .tar.gz\n") 44 | 45 | try: 46 | subprocess.check_call(cmd) 47 | except subprocess.CalledProcessError as e: 48 | raise ImageException("Error calling gzip export to compress .tar file\n%s" % e) 49 | 50 | return (tar_dir, tar_gz_file) 51 | 52 | 53 | class QuietSimpleHTTPHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): 54 | def log_message(self, format, *args): 55 | pass 56 | 57 | 58 | def http_server(dir, ip, port, verbose): 59 | """Use Python's Simple HTTP server to expose the image over HTTP for 60 | clair to grab it. 61 | """ 62 | chdir(dir) 63 | if verbose: 64 | sys.stderr.write("Serving Image to Clair from http://%s:%d\n" % (ip, port)) 65 | Handler = SimpleHTTPServer.SimpleHTTPRequestHandler 66 | else: 67 | Handler = QuietSimpleHTTPHandler 68 | 69 | httpd = socketserver.TCPServer((ip, port), Handler) 70 | httpd.serve_forever() 71 | -------------------------------------------------------------------------------- /clair_singularity/util.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import sys 4 | import socket 5 | from contextlib import closing 6 | 7 | 8 | def sha256(fname): 9 | """Compute sha256 hash for file fname""" 10 | hash_sha256 = hashlib.sha256() 11 | with open(fname, "rb") as f: 12 | for chunk in iter(lambda: f.read(65536), b""): 13 | hash_sha256.update(chunk) 14 | return hash_sha256.hexdigest() 15 | 16 | 17 | def pretty_json(obj): 18 | """Format an object into json nicely""" 19 | return json.dumps(obj, separators=(',', ':'), sort_keys=True, indent=2) 20 | 21 | 22 | def err_and_exit(e, code=1): 23 | """Write exception to STDERR and exit with supplied code""" 24 | sys.stderr.write(str(e)) 25 | sys.exit(code) 26 | 27 | def find_free_port(): 28 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: 29 | s.bind(('', 0)) 30 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 31 | return s.getsockname()[1] 32 | 33 | # http://code.activestate.com/recipes/576655-wait-for-network-service-to-appear/ 34 | # 35 | # 36 | # Copyright (c) 2017 ActiveState Software Inc. 37 | # 38 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 39 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation 40 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 41 | # and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 42 | # 43 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of 44 | # the Software. 45 | # 46 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 47 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 48 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 49 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 50 | 51 | def wait_net_service(server, port, timeout=None): 52 | """ Wait for network service to appear 53 | @param timeout: in seconds, if None or 0 wait forever 54 | @return: True of False, if timeout is None may return only True or 55 | throw unhandled network exception 56 | """ 57 | import socket 58 | 59 | s = socket.socket() 60 | if timeout: 61 | from time import time as now 62 | # time module is needed to calc timeout shared between two exceptions 63 | end = now() + timeout 64 | 65 | while True: 66 | try: 67 | if timeout: 68 | next_timeout = end - now() 69 | if next_timeout < 0: 70 | return False 71 | else: 72 | s.settimeout(next_timeout) 73 | 74 | s.connect((server, port)) 75 | 76 | except (socket.timeout, socket.error): 77 | pass 78 | 79 | else: 80 | s.close() 81 | return True 82 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtrudg/clair-singularity/c4fc2e7b3bc480225a469b015b5f3eef440eae42/screenshot.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [aliases] 5 | test=pytest 6 | 7 | [tool:pytest] 8 | flake8-max-line-length = 99 9 | markers = needs_clair -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scan Singularity container images using CoreOS Clair. 3 | """ 4 | from setuptools import find_packages, setup 5 | 6 | dependencies = ['click', 'six', 'requests', 'texttable'] 7 | 8 | setup( 9 | name='clair_singularity', 10 | version='0.4.0', 11 | url='https://github.com/dctrud/clair-singularity', 12 | author='David Trudgian', 13 | author_email='dtrudg@sylabs.io', 14 | description='Scan Singularity container images using CoreOS Clair.', 15 | long_description=__doc__, 16 | packages=find_packages(exclude=['tests']), 17 | include_package_data=True, 18 | zip_safe=False, 19 | platforms='any', 20 | install_requires=dependencies, 21 | setup_requires=['pytest-runner'], 22 | tests_require=['pytest', 'pytest-cov', 'pytest-flake8'], 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'clair-singularity = clair_singularity.cli:cli', 26 | ], 27 | }, 28 | classifiers=[ 29 | # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers 30 | # 'Development Status :: 1 - Planning', 31 | # 'Development Status :: 2 - Pre-Alpha', 32 | # 'Development Status :: 3 - Alpha', 33 | 'Development Status :: 4 - Beta', 34 | # 'Development Status :: 5 - Production/Stable', 35 | # 'Development Status :: 6 - Mature', 36 | # 'Development Status :: 7 - Inactive', 37 | 'Environment :: Console', 38 | 'Intended Audience :: Developers', 39 | 'Operating System :: POSIX :: Linux', 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 2', 42 | 'Programming Language :: Python :: 3', 43 | 'Topic :: Software Development :: Quality Assurance', 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtrudg/clair-singularity/c4fc2e7b3bc480225a469b015b5f3eef440eae42/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_clair.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clair_singularity.clair import check_clair, post_layer, get_report 4 | 5 | API_URL = 'http://localhost:6060/v1/' 6 | 7 | @pytest.mark.needs_clair 8 | def test_check_clair(): 9 | # We can talk to the API 10 | assert check_clair(API_URL,False) 11 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | import socket 4 | from click.testing import CliRunner 5 | from clair_singularity.cli import cli 6 | 7 | from .test_image import testimage 8 | 9 | 10 | MY_IP = socket.gethostbyname(socket.gethostname()) 11 | 12 | 13 | @pytest.fixture 14 | def runner(): 15 | return CliRunner() 16 | 17 | 18 | def test_help(runner): 19 | result = runner.invoke(cli, ['--help']) 20 | assert 'Usage:' in result.output 21 | 22 | 23 | @pytest.mark.needs_clair 24 | def test_full_json(runner, testimage): 25 | result = runner.invoke(cli, 26 | ['--json-output', '--bind-ip', MY_IP, '--bind-port', '8081', '--clair-uri', 27 | 'http://localhost:6060', testimage]) 28 | output = json.loads(result.output) 29 | 30 | # There are 62 features in the container scan, and 18 have vulnerabilities 31 | assert 'Layer' in output 32 | assert 'Features' in output['Layer'] 33 | assert len(output['Layer']['Features']) == 62 34 | features_with_vuln = 0 35 | for feature in output['Layer']['Features']: 36 | if 'Vulnerabilities' in feature: 37 | features_with_vuln = features_with_vuln + 1 38 | assert features_with_vuln == 18 39 | 40 | 41 | @pytest.mark.needs_clair 42 | def test_full_text(runner, testimage): 43 | result = runner.invoke(cli, ['--bind-ip', MY_IP, '--bind-port', '8082', '--clair-uri', 44 | 'http://localhost:6060', testimage]) 45 | # Check we do have some CVEs we expect reported here 46 | assert 'coreutils' in result.output 47 | assert 'CVE' in result.output 48 | -------------------------------------------------------------------------------- /tests/test_image.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | import subprocess 4 | import time 5 | 6 | import pytest 7 | import requests 8 | 9 | from clair_singularity.image import image_to_tgz, check_image, http_server, ImageException 10 | from clair_singularity.util import sha256, err_and_exit, wait_net_service 11 | 12 | 13 | @pytest.fixture 14 | def testimage(tmpdir): 15 | """Fetch a test singularity image""" 16 | cwd = os.getcwd() 17 | os.chdir(tmpdir.strpath) 18 | subprocess.check_output(['singularity', 'pull', 'library://library/default/ubuntu:sha256.cb37e547a14249943c5a3ee5786502f8db41384deb83fa6d2b62f3c587b82b17']) 19 | os.chdir(cwd) 20 | return os.path.join(tmpdir.strpath, 'ubuntu_sha256.cb37e547a14249943c5a3ee5786502f8db41384deb83fa6d2b62f3c587b82b17.sif') 21 | 22 | 23 | def test_check_image(testimage): 24 | # Valid image return True 25 | assert check_image(testimage) 26 | # Sys exit for invalid image 27 | with pytest.raises(ImageException) as pytest_wrapped_e: 28 | check_image('i_do_not_exist.img') 29 | assert pytest_wrapped_e.type == ImageException 30 | 31 | 32 | def test_image_to_tgz(testimage): 33 | (temp_dir, tar_file) = image_to_tgz(testimage, False) 34 | # Should have created a temporary dir 35 | assert os.path.isdir(temp_dir) 36 | # The tar.gz should exist 37 | assert os.path.isfile(tar_file) 38 | 39 | def test_http_server(testimage, tmpdir): 40 | """Test we can retrieve a test file from in-built http server faithfully""" 41 | httpd = multiprocessing.Process(target=http_server, 42 | args=(os.path.dirname(testimage), '127.0.0.1', 8088, False)) 43 | httpd.daemon = True 44 | httpd.start() 45 | # Allow up to 30 seconds for the httpd to start and be answering requests 46 | httpd_ready = wait_net_service('127.0.0.1', 8088, 30) 47 | if not httpd_ready: 48 | httpd.terminate() 49 | err_and_exit("HTTP server did not become ready", 1) 50 | 51 | r = requests.get('http://127.0.0.1:8088/ubuntu_sha256.cb37e547a14249943c5a3ee5786502f8db41384deb83fa6d2b62f3c587b82b17.sif', stream=True) 52 | 53 | tmpfile = os.path.join(tmpdir.strpath, 'downloaded.sif') 54 | # Check the file is good 55 | with open(tmpfile, 'wb') as fd: 56 | for block in r.iter_content(1024): 57 | fd.write(block) 58 | 59 | httpd.terminate() 60 | 61 | assert r.status_code == requests.codes.ok 62 | assert sha256(tmpfile) == sha256(testimage) 63 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from clair_singularity.util import sha256 2 | 3 | 4 | def test_sha256(): 5 | """Check we can get a sha256 on something that won't change often""" 6 | assert sha256('.gitignore') == \ 7 | 'da04d844bb8a1fd051cfc7cb8bba1437f3f237f48d2974d72f749ad7fbfd1d96' 8 | --------------------------------------------------------------------------------