· 6 years ago · Jun 04, 2019, 08:24 PM
1#!/usr/bin/env python
2from __future__ import print_function
3import docker, requests, os, sys, json, time, argparse
4from dateutil.parser import parse as dateparse
5from requests.packages.urllib3 import Retry
6from colorama import init, deinit, Fore, Back, Style
7from requests.packages.urllib3.exceptions import (
8 InsecureRequestWarning, InsecurePlatformWarning, SNIMissingWarning)
9
10# These warning will always fire on older versions of python, we just want to
11# ignore them for now.
12requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
13requests.packages.urllib3.disable_warnings(InsecurePlatformWarning)
14requests.packages.urllib3.disable_warnings(SNIMissingWarning)
15
16__author__ = 'Steve McGrath <smcgrath@tenable.com>'
17__version__ = '1.0.1'
18
19
20class APIError(Exception):
21 def __init__(self, code, msg):
22 self.code = code
23 self.msg = msg
24
25 def __str__(self):
26 return repr('[%s]: %s' % (self.code, self.msg))
27
28
29class ContainerSecurity(object):
30 _uri = 'https://cloud.tenable.com/container-security/api/v1'
31 _registry = 'registry.cloud.tenable.com'
32 _timer = 5
33 _access_key = None
34 _secret_key = None
35
36 def __init__(self, access_key, secret_key, **kwargs):
37 self._access_key = access_key
38 self._secret_key = secret_key
39
40 # Sets the retry adaptor with the ability to properly backoff if we get 429s
41 retries = Retry(
42 total=3,
43 status_forcelist={429, 501, 502, 503, 504},
44 backoff_factor=0.1,
45 respect_retry_after_header=True
46 )
47 adapter = requests.adapters.HTTPAdapter(max_retries=retries)
48
49 # initiate the session and then attach the Retry adaptor.
50 self._session = requests.Session()
51 self._session.mount('https://', adapter)
52
53 # we need to make sure to identify ourselves.
54 self._session.headers.update({
55 'User-Agent': 'CSUp/{} Python/{}'.format(__version__, '.'.join([str(i) for i in sys.version_info][0:3])),
56 'X-APIKeys': 'accessKey={}; secretKey={};'.format(access_key, secret_key)
57 })
58
59 if 'uri' in kwargs and kwargs['uri']:
60 self._uri = kwargs['uri']
61 if 'registry' in kwargs and kwargs['registry']:
62 self._registry = kwargs['registry']
63
64 def _request(self, method, path, return_json=True, **kwargs):
65 '''
66 HTTP Request Function
67
68 Args:
69 method (str): The HTTP Method being used for the call, such as GET,
70 POST, PUT, DELETE
71 path (str): The URL path for the the API call.
72 return_json (optional, bool): Should the returned object be a Python
73 dictionary or should the raw HTTP response be returned. Default
74 is True.
75 **kwargs (optional, dict): Any keywords that want to be passed back
76 to the Python requests object.
77
78 Returns:
79 dict: Python dictionary representing the JSON document returned from
80 the API.
81 requests.Response: The raw HTTP Response instance.
82 '''
83 resp = self._session.request(method, '{}/{}'.format(self._uri, path), **kwargs)
84
85 # If we aren't streaming the response back to the caller, then we should
86 # check to see if the response is valid and raise an APIError exception
87 # if an error is returned back.
88 if 'stream' in kwargs and kwargs['stream']:
89 return resp
90 elif return_json:
91 try:
92 # Lets check for an error code in the returned JSON dictionary.
93 # if one exists, then lets raise an exception. Otherwise, we
94 # should return the Python dictionary that we have already
95 # computed out.
96 return resp.json()
97 except ValueError:
98 # obviously something went wrong with the JSON parser, so lets
99 # just return the response object.
100 return resp
101 else:
102 # We were told not to return json data and not to stream, so lets just
103 # return the response object
104 return resp
105
106 def _waiting(self, image_id):
107 '''
108 Wait for the job status of the image to either report completed or failed.
109 '''
110 done = False
111 while not done:
112 status = self.status(image_id)
113 if 'job_status' in status:
114 done = status['job_status'] in ['completed', 'failed']
115 if not done:
116 time.sleep(self._timer)
117 return status['job_status'] == 'completed'
118
119 def status(self, image_id):
120 '''
121 Return the Image Test Status
122
123 Args:
124 image_id (str): The Docker short image id
125
126 Returns:
127 dict: The image status document
128 '''
129 return self._request('GET', 'jobs/image_status', params={
130 'image_id': image_id
131 })
132
133 def policy(self, image_id, block=False):
134 '''
135 Return the Policy Compliance Status for the Image
136 '''
137 if block:
138 if not self._waiting(image_id):
139 return None
140 return self._request('GET', 'policycompliance', params={
141 'image_id': image_id
142 })
143
144 def report(self, image_id, block=False):
145 '''
146 Return the Test Results for the Image
147 '''
148 if block:
149 if not self._waiting(image_id):
150 return None
151 return self._request('GET', 'reports/by_image_digest', params={
152 'image_digest': image_id
153 })
154
155 def docker_upload(self, name, tag=None, cs_name=None, cs_tag=None):
156 '''
157 Uploads an image into Tenable.io Container Security
158 '''
159 if not cs_name:
160 cs_name = 'consectool/{}'.format(name)
161 if not cs_tag:
162 if tag:
163 cs_tag = tag
164 else:
165 cs_tag = 'latest'
166 d = docker.from_env()
167 image = d.images.get('{}:{}'.format(name, tag) if tag else name)
168 repo = '{}/{}:{}'.format(self._registry, cs_name, cs_tag)
169 image.tag(repo)
170 d.images.push(repo, auth_config={
171 'username': self._access_key,
172 'password': self._secret_key
173 })
174 d.images.remove(repo)
175 return image.id.split(':')[1][:12]
176
177 def list_images(self):
178 '''
179 Gets the list of known Images in container security
180 '''
181 return self._request('GET', 'container/list')
182
183
184def color(color, msg, colored=True):
185 if colored:
186 return '{}{}{}'.format(color, msg, Style.RESET_ALL)
187 else:
188 return msg
189
190
191def sevcolor(score, colored=True):
192 if not colored:
193 return score
194 try:
195 fscore = float(score)
196 except ValueError:
197 return score
198 else:
199 if fscore > 9.9: # Anything more than 9.9 is CRITICAL
200 return color(Fore.MAGENTA, score)
201 elif fscore >= 7.0: # Anything more than 7.0 is HIGH
202 return color(Fore.RED, score)
203 elif fscore >= 4.0: # Anything more than 4.0 is MEDIUM
204 return color(Fore.YELLOW, score)
205 else: # Anything below 4.0 is LOW
206 return color(Fore.GREEN, score)
207
208def main():
209 '''
210 Main Function
211 '''
212 # Global Arguments
213 parser = argparse.ArgumentParser(description='''
214 Container Security UPloading and reporting tool (CSUP) is a commandline tool
215 designed to interface into Tenable.io's Container Security toolset for the
216 purposes of uploading docker containers, fetching the image reports, and
217 checking the policy compliance and status of the image tests.
218
219 The global arguments must come before the action and inform csup how to
220 communicate to Container Security.
221 ''')
222 subparsers = parser.add_subparsers(
223 dest='action',
224 title='valid actions',
225 help='additional help available')
226 parser.add_argument('--access-key',
227 dest='access',
228 help='Tenable.io API access key',
229 default=os.environ.get('TIO_ACCESS_KEY'))
230 parser.add_argument('--secret-key',
231 dest='secret',
232 help='Tenable.io API secret sey',
233 default=os.environ.get('TIO_SECRET_KEY'))
234 parser.add_argument('--consec-path',
235 dest='registry',
236 help='alternative Tenable.io registry address',
237 default=os.environ.get('TIO_CS_ADDRESS'))
238 parser.add_argument('--tio-path',
239 dest='api',
240 help='alternative Tenable.io URI',
241 default=os.environ.get('CS_API'))
242
243 # Upload Subparser options
244 parser_upload = subparsers.add_parser('upload', description='''
245 The upload action will upload a docker image to Tenable.io Container
246 Security and then if specified, will wait for the policy status and/or
247 report to return. If either the policy returns a non-passing status or if
248 any of the report thresholds have been tripped, csup will return a
249 non-zero status code.
250 ''')
251 parser_upload.add_argument('id',
252 help='container image name or id')
253 parser_upload.add_argument('-t', '--tag',
254 dest='tag',
255 help='container image tag')
256 parser_upload.add_argument('-N', '--consec-name',
257 dest='consec_name',
258 help='ContainerSecurity repository & image path')
259 parser_upload.add_argument('-T', '--consec-tag',
260 dest='consec_tag',
261 help='ContainerSecurity image tag',
262 default='latest')
263 parser_upload.add_argument('-r', '--report',
264 dest='report',
265 help='output the test results when tests have completed',
266 action='store_true')
267 parser_upload.add_argument('-p', '--policy',
268 dest='policy',
269 help='output the compliance status when tests have completed',
270 action='store_true')
271 parser_upload.add_argument('--no-wait',
272 dest='sleep',
273 help=argparse.SUPPRESS,
274 action='store_false')
275 parser_upload.add_argument('--json',
276 dest='json',
277 help='returns the data as a JSON object instead of formatted text',
278 action='store_true')
279 parser_upload.add_argument('--no-color',
280 dest='colored',
281 help='Remove colorization from the output',
282 action='store_false',
283 default=True)
284
285 # Report Subparser options
286 parser_report = subparsers.add_parser('report', description='''
287 Retrieve a report for the image ID specified. If any of the thresholds are
288 set (and then met), then return a non-zero return status code.
289 ''')
290 parser_report.add_argument('id',
291 help='container image id')
292 parser_report.add_argument('-w', '--wait',
293 dest='sleep',
294 help='wait for testing of the image to complete',
295 action='store_true')
296 parser_report.add_argument('--json',
297 dest='json',
298 help='returns the data as a JSON object instead of formatted text',
299 action='store_true')
300 parser_report.add_argument('--no-color',
301 dest='colored',
302 help='Remove colorization from the output',
303 action='store_false',
304 default=True)
305
306
307 # Policy Subparser options
308 parser_policy = subparsers.add_parser('policy', description='''
309 Retrieve the policy status for the image ID specified. If the policy
310 compliance status is a failure, then return a non-zero status code.
311 ''')
312 parser_policy.add_argument('id',
313 help='container image id')
314 parser_policy.add_argument('-w', '--wait',
315 dest='sleep',
316 help='wait for testing of the image to complete',
317 action='store_true')
318 parser_policy.add_argument('--json',
319 dest='json',
320 help='returns the data as a JSON object instead of formatted text',
321 action='store_true')
322 parser_policy.add_argument('--no-color',
323 dest='colored',
324 help='Remove colorization from the output',
325 action='store_false',
326 default=True)
327
328 # Status subparser options
329 parser_status = subparsers.add_parser('status', description='''
330 Get the current job status of the image ID specified.
331 ''')
332 parser_status.add_argument('id',
333 help='image id')
334 parser_status.add_argument('--json',
335 dest='json',
336 help='returns the data as a JSON object instead of formatted text',
337 action='store_true')
338 parser_status.add_argument('--no-color',
339 dest='colored',
340 help='Remove colorization from the output',
341 action='store_false',
342 default=True)
343
344 #parser_list = subparsers.add_parser('list', description='''
345 #Get the listing of images currently enumerated in Container Security
346 #''')
347 #parser_list.add_argument('--json',
348 # dest='json',
349 # help='returns the data as a JSON object instead of formatted text',
350 # action='store_true')
351
352 # process the arguments and instantiate the ContainerSecurity object and
353 # data dictionary.
354 args = parser.parse_args()
355 consec = ContainerSecurity(args.access, args.secret, uri=args.api, registry=args.registry)
356 data = {}
357 exit_code = 0
358
359 # If the action was to upload an image, we want to make sure to do that first.
360 if args.action == 'upload':
361 ustart = time.time()
362 data['upload'] = {
363 'image_id': consec.docker_upload(args.id, tag=args.tag, cs_name=args.consec_name, cs_tag=args.consec_tag),
364 'upload_time': int(time.time() - ustart)
365 }
366
367 if args.action == 'list':
368 data['list'] = consec.list_images()
369
370 # Now we want to make sure to use the appropriate image id. If we have
371 # uploaded an image, then we will want to use the returned image id form that
372 # image, otherwise we want to use the image id that was provided as an argument
373 if 'upload' in data and 'image_id' in data['upload']:
374 image_id = data['upload']['image_id']
375 else:
376 if hasattr(args, 'id'):
377 image_id = args.id
378
379 # Run the report, policy, and status actions if required.
380 if args.action == 'report' or (args.action == 'upload' and args.report):
381 data['report'] = consec.report(image_id, block=args.sleep)
382 if args.action == 'policy' or (args.action == 'upload' and args.policy):
383 data['policy'] = consec.policy(image_id, block=args.sleep)
384 if args.action in ['status', 'upload', 'policy', 'report']:
385 data['status'] = consec.status(image_id)
386
387
388 if args.json:
389 # If the User requested that the output be in JSON format, then we should
390 # simply return a JSON formatted reponse from the data disctionary
391 print(json.dumps(data, sort_keys=True, indent=4))
392 else:
393 # Here we will attempt to interpret the output presented fromt he actions
394 # above and present the data in a readable format for a commandline app.
395 output = []
396 if 'upload' in data and data['upload']:
397 # Output the upload statistics if we had uploaded an image
398 output.append('Uploaded Image: {}'.format(data['upload']['image_id']))
399 output.append('Upload Time: {} seconds'.format(data['upload']['upload_time']))
400
401 if 'list' in data and data['list']:
402 for item in data['list']:
403 line = '{id} {name:30} {size} {score}'.format(
404 )
405 output.append(line)
406
407 if 'status' in data and data['status']:
408 # Output the status information about the image testing job(s).
409 if data['status'] == 'not_found':
410 output.append('Could not find Image')
411 exit_code = 1
412 elif 'job_status' in data['status']:
413 col = Fore.YELLOW
414 if data['status']['job_status'] == 'completed': col = Fore.GREEN
415 if data['status']['job_status'] == 'failed': col = Fore.RED
416 line = 'Test Status: {}'.format(color(
417 col, data['status']['job_status'].upper(), args.colored))
418 if data['status']['job_status'] in ['completed', 'failed']:
419 duration = (dateparse(data['status']['updated_at']) - dateparse(data['status']['created_at'])).seconds
420 line += ' in {} seconds'.format(duration)
421 if data['status']['job_status'] == 'failed':
422 exit_code = 1
423 output.append(line)
424
425 if 'policy' in data and data['policy']:
426 # Output the policy compliance information about the image tested.
427 if 'error' in data['policy'] and data['policy']['error'] in ['image not found', 'Internal Server Error']:
428 # If we encountered an error, then we need to display the error and bail.
429 output.append('Could not perform policy action: {}'.format(r['error']))
430 exit_code = 1
431 else:
432 col = Fore.RED
433 if data['policy']['status'] == 'pass':
434 col = Fore.GREEN
435 output.append('Compliance Status: {}'.format(
436 color(col, data['policy']['status'].upper(), args.colored)))
437
438 if 'report' in data and data['report']:
439 # Output the report information about the image tested.
440 r = data['report']
441 if 'error' in r and r['error'] in ['image not found', 'Internal Server Error']:
442 # If we encountered an error, then we need to display the error and bail.
443 output.append('Could not perform report action: {}'.format(r['error']))
444 exit_code = 1
445 else:
446 # some formatted output of most of the main fields (but not all)
447 output.append('Docker Image Id: {}'.format(r['docker_image_id']))
448 output.append('Image Name: {}:{}'.format(r['image_name'], r['tag']))
449 output.append('SHA256 Hash: {}'.format(r['sha256']))
450 output.append('Operating System: {}'.format(r['os']))
451 output.append('OS Version: {}'.format(r['os_version']))
452 output.append('Architecture: {}'.format(r['os_architecture']))
453 output.append('Risk Score: {}'.format(
454 sevcolor(r['risk_score'], args.colored)))
455 output.append('Image Created: {}'.format(r['created_at']))
456 output.append('Image Updated: {}'.format(r['updated_at']))
457
458 # Iterate through all of the artifacts discovered and display them
459 # in ARTIFACT=VERSION format, one per line.
460 if len(r['installed_packages']) > 0:
461 output.append('Artifacts Discovered:')
462 for artifact in r['installed_packages']:
463 output.append('\t- {}={}'.format(artifact['name'], artifact['version']))
464
465 # Iterate throug all of the findings discovered through testing
466 # and display the CVSS Score, CVE, and artifacts impacted for each.
467 # We may want to display more detail down the road, however it gets
468 # quite wordy and I wanted to keep the overall output of the formatted
469 # data pretty concise.
470 if len(r['findings']) > 0:
471 output.append('Findings Discovered:')
472 for finding in r['findings']:
473 output.append('\t{} {} [{}]'.format(
474 sevcolor(finding['nvdFinding']['cvss_score'], args.colored),
475 finding['nvdFinding']['cve'],
476 ', '.join(['{}={}'.format(p['name'], p['version']) for p in finding['packages']])
477 ))
478
479 # Iterate through all of the malware discovered and display them
480 # in ARTIFACT=SHASUM format, one per line.
481 if len(r['malware']) > 0:
482 output.append('Malware Discovered:')
483 for m in r['malware']:
484 output.append('\t- {}={}'.format(m['file'], m['sha256']))
485
486 # Iterate through all of the PUPs discovered and display them
487 # in ARTIFACT=SHASUM format, one per line.
488 if len(r['potentially_unwanted_programs']) > 0:
489 output.append('Potentially Unwanted Programs Discovered:')
490 for i in r['potentially_unwanted_programs']:
491 output.append('\t- {}={}'.format(i['file'], i['sha256']))
492
493 # Output everything to STDOut.
494 print('\n'.join(output))
495
496 # Now we need to check to see if we need to return a exit code other than 0.
497 if ('policy' in data
498 and data['policy']
499 and 'status' in data['policy']
500 and data['policy']['status'] != 'pass'):
501 # If we see anything in the policy status other than "pass", we will
502 # return a status code of 1
503 exit_code = 1
504 sys.exit(exit_code)
505
506
507if __name__ == '__main__':
508 main()