Skip to content

Commit 09d7dde

Browse files
authored
Formatting URL to work with Quay and adding support for fat manifests (#19)
1 parent db5d1ec commit 09d7dde

File tree

10 files changed

+307
-44
lines changed

10 files changed

+307
-44
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
venv/
2+
build/
3+
claircli.egg-info/
4+
dist/
5+
__pycache__/
6+
.coverage
7+
htmlcov/
8+
.eggs/
9+
.tox/

claircli/cli.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -177,46 +177,56 @@ def analyze_image(self):
177177
stats = defaultdict(list)
178178
for index, image in enumerate(args.images, start=1):
179179
logger.info('{:*^60}'.format(index))
180-
try:
181-
clair.analyze_image(image)
182-
report = clair.get_report(image)
183-
if not report:
184-
stats['IMAGES WERE NOT SUPPORTED'].append(image.name)
185-
continue
186-
report.process_data(args.threshold, args.white_list)
187-
report.to_console()
188-
for format_ in args.formats:
189-
report.to(format_)
190-
if report.ok:
191-
stats['IMAGES WITHOUT DETECTED VULNERABILITIES'].append(
192-
image.name)
193-
else:
194-
stats['IMAGES WITH DETECTED VULNERABILITIES'].append(
195-
image.name)
196-
except exceptions.HTTPError as exp:
197-
if exp.response.status_code in [400, 404] and \
198-
('Not Found for url' in str(exp) or
199-
'no such image' in str(exp)):
200-
logger.warning('%s was not found', image)
201-
stats['IMAGES COULD NOT BE FOUND'].append(image.name)
202-
else:
203-
logger.warning('Could not analyze %s: Got response %d '
204-
'from clair with message: %s',
205-
image.name, exp.response.status_code,
206-
exp.response.text)
207-
stats['IMAGES COULD NOT BE ANALYZED'].append(
208-
image.name)
209-
except KeyboardInterrupt:
210-
logger.warning('Keyboard interrupted')
211-
return 2
212-
except Exception as exp:
213-
stats['IMAGES WERE ANALYZED WITH ERROR'].append(image.name)
214-
logger.warning(str(exp))
215-
logger.debug('Underlying problem:', exc_info=exp)
216-
finally:
217-
image.clean()
180+
# Check whether we're examining a "fat manifest" or a regular image
181+
if image.images:
182+
logger.info('Analyzing manifest list ("fat manifest")...')
183+
for sub_image in image.images:
184+
self._analyze_single_image(args, clair, sub_image, stats)
185+
else:
186+
self._analyze_single_image(args, clair, image, stats)
187+
218188
return self.print_stats(stats)
219189

190+
def _analyze_single_image(self, args, clair, image, stats):
191+
try:
192+
clair.analyze_image(image)
193+
report = clair.get_report(image)
194+
if not report:
195+
stats['IMAGES WERE NOT SUPPORTED'].append(image.name)
196+
return
197+
report.process_data(args.threshold, args.white_list)
198+
report.to_console()
199+
for format_ in args.formats:
200+
report.to(format_)
201+
if report.ok:
202+
stats['IMAGES WITHOUT DETECTED VULNERABILITIES'].append(
203+
image.name)
204+
else:
205+
stats['IMAGES WITH DETECTED VULNERABILITIES'].append(
206+
image.name)
207+
except exceptions.HTTPError as exp:
208+
if exp.response.status_code in [400, 404] and \
209+
('Not Found for url' in str(exp) or
210+
'no such image' in str(exp)):
211+
logger.warning('%s was not found', image)
212+
stats['IMAGES COULD NOT BE FOUND'].append(image.name)
213+
else:
214+
logger.warning('Could not analyze %s: Got response %d '
215+
'from clair with message: %s',
216+
image.name, exp.response.status_code,
217+
exp.response.text)
218+
stats['IMAGES COULD NOT BE ANALYZED'].append(
219+
image.name)
220+
except KeyboardInterrupt:
221+
logger.warning('Keyboard interrupted')
222+
return 2
223+
except Exception as exp:
224+
stats['IMAGES WERE ANALYZED WITH ERROR'].append(image.name)
225+
logger.warning(str(exp))
226+
logger.debug('Underlying problem:', exc_info=exp)
227+
finally:
228+
image.clean()
229+
220230
def print_stats(self, stats):
221231
total = sum(map(len, stats.values()))
222232
logger.info('=' * 60)

claircli/docker_image.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99

1010
logger = logging.getLogger(__name__)
1111

12+
MANIFEST_LIST_V2 = 'application/vnd.docker.distribution.manifest.list.v2+json'
13+
MANIFEST_V2 = 'application/vnd.docker.distribution.manifest.v2+json'
14+
1215

1316
class Image(object):
1417

1518
def __init__(self, name, registry=None):
1619
self.name = name
1720
self._layers = []
21+
self._images = []
1822
self._manifest = None
1923
reg, repo, tag = self.parse_id(name)
2024
self.repository = repo
@@ -48,6 +52,22 @@ def manifest(self):
4852
self._manifest = self.registry.get_manifest(self)
4953
return self._manifest
5054

55+
@property
56+
def images(self):
57+
if not self._images:
58+
images_list = []
59+
manifest = self.manifest
60+
if isinstance(self.registry, LocalRegistry):
61+
pass
62+
elif manifest['schemaVersion'] == 2 and manifest['mediaType'] \
63+
== MANIFEST_LIST_V2:
64+
for single_manifest in manifest['manifests']:
65+
reg, repo, _tag = self.parse_id(self.name)
66+
images_list.append(Image('{}/{}@{}'.format(
67+
reg, repo, single_manifest['digest'])))
68+
self._images = images_list
69+
return self._images
70+
5171
@property
5272
def layers(self):
5373
if not self._layers:
@@ -58,8 +78,12 @@ def layers(self):
5878
elif manifest['schemaVersion'] == 1:
5979
self._layers = [e['blobSum']
6080
for e in manifest['fsLayers']][::-1]
61-
elif manifest['schemaVersion'] == 2:
81+
elif manifest['schemaVersion'] == 2 and manifest['mediaType'] \
82+
== MANIFEST_V2:
6283
self._layers = [e['digest'] for e in manifest['layers']]
84+
elif manifest['schemaVersion'] == 2 and manifest['mediaType'] \
85+
== MANIFEST_LIST_V2:
86+
self._layers = []
6387
else:
6488
raise ValueError(
6589
'Wrong schemaVersion [%s]' % manifest['schemaVersion'])

claircli/docker_registry.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,16 @@ def get_manifest(self, image):
111111
self.url, image=image)
112112
headers = {'Accept':
113113
'application/vnd.docker.distribution.manifest.v2+json,'
114-
'application/vnd.docker.distribution.manifest.v1+json',
114+
'application/vnd.docker.distribution.manifest.v1+json,'
115+
'application/vnd.docker.distribution.manifest.list.v2+json',
115116
'Authorization': self.get_auth(image.repository)}
116117
resp = request_and_check('GET', url, headers=headers)
117118
return resp.json()
118119

119120
def get_blobs_url(self, image, layer):
120-
return '/'.join([self.url, image.repository, 'blobs', layer])
121+
return '/'.join(f.strip('/') for f in [
122+
self.url, image.repository, 'blobs', layer
123+
])
121124

122125
def find_images(self, repository, tag):
123126
if self.domain == DOCKER_HUP_REGISTRY:

setup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
'colorlog',
1313
]
1414

15+
test_requirements = [
16+
'responses'
17+
]
18+
1519
here = os.path.abspath(os.path.dirname(__file__))
1620
about = {}
1721
with open(os.path.join(here, 'claircli', '__version__.py')) as f:
@@ -31,6 +35,7 @@
3135
author_email=about['__author_email__'],
3236
packages=['claircli'],
3337
install_requires=requires,
38+
tests_require=test_requirements,
3439
license=about['__license__'],
3540
package_data={'': ['LICENSE'], 'claircli': ['templates/html-report.j2']},
3641
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*,'

tests/test_claircli.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ def setUp(self):
6464

6565
def tearDown(self):
6666
RemoteRegistry.tokens = defaultdict(dict)
67-
if isfile(self.html):
68-
os.remove(self.html)
67+
# if isfile(self.html):
68+
# os.remove(self.html)
6969

7070
def assert_called_with_url(self):
7171
self.assertEqual(responses.calls[0].request.url, self.reg_url)
@@ -100,6 +100,26 @@ def test_manifest(self):
100100
self.assertEqual(image.manifest, self.manifest)
101101
self.assert_called_with_url()
102102

103+
@responses.activate
104+
def test_list_manifest(self):
105+
with open('tests/test_data/manifest.list.v2.json') as f:
106+
list_manifest = json.load(f)
107+
responses.replace(responses.GET, self.manifest_url,
108+
json=list_manifest, status=200)
109+
image = Image(self.name)
110+
self.assertEqual(image.manifest, list_manifest)
111+
self.assert_called_with_url()
112+
113+
@responses.activate
114+
def test_unsupported_manifest(self):
115+
with open('tests/test_data/manifest.unsupported.json') as f:
116+
manifest = json.load(f)
117+
responses.replace(responses.GET, self.manifest_url,
118+
json=manifest, status=200)
119+
with self.assertRaises(ValueError):
120+
image = Image(self.name)
121+
image.layers
122+
103123
@patch('docker.from_env')
104124
def test_manifest_local(self, mock_docker):
105125
mock_docker_client(mock_docker)
@@ -137,6 +157,27 @@ def test_layers_v2(self):
137157
[e['digest'] for e in self.manifest['layers']])
138158
self.assert_called_with_url()
139159

160+
@responses.activate
161+
def test_layers_list_v2(self):
162+
list_image_manifest_url = self.reg_url + \
163+
'org/image-name/manifests/sha256:d0fec089e611891a03f3282f10115bb186ed46093c3f083eceb250cee64b63eb'
164+
165+
with open('tests/test_data/manifest.list.v2.json') as f:
166+
list_manifest = json.load(f)
167+
with open('tests/test_data/manifest.list.v2-image.json') as f:
168+
list_image_manifest = json.load(f)
169+
responses.replace(responses.GET, self.manifest_url,
170+
json=list_manifest, status=200)
171+
responses.add(responses.GET, list_image_manifest_url,
172+
json=list_image_manifest, status=200)
173+
image = Image(self.name)
174+
self.assertEqual(image.images[0].layers, [e['digest']
175+
for e in list_image_manifest['layers']])
176+
self.assertEqual(image.layers, [])
177+
self.assert_called_with_url()
178+
self.assertEqual(
179+
responses.calls[3].request.url, list_image_manifest_url)
180+
140181

141182
class TestClair(ClairCmdTestBase):
142183

@@ -196,7 +237,7 @@ def test_read_white_list(self):
196237

197238
@responses.activate
198239
def test_analyze_images(self):
199-
with patch('sys.argv', ['claircli', '-c',
240+
with patch('sys.argv', ['claircli', '-d', '-c',
200241
self.clair_url, self.name]):
201242
cli = ClairCli()
202243
cli.run()
@@ -316,3 +357,41 @@ def test_analyze_local_images(self, mock_docker):
316357
req_body = json.loads(responses.calls[index].request.body)
317358
self.assertEqual(req_body['Layer']['Name'], layer)
318359
self.assertTrue(isfile(self.html))
360+
361+
362+
@responses.activate
363+
def test_analyze_manifest_list(self):
364+
list_image_manifest_url = self.reg_url + \
365+
'org/image-name/manifests/sha256:d0fec089e611891a03f3282f10115bb186ed46093c3f083eceb250cee64b63eb'
366+
with open('tests/test_data/manifest.list.v2.json') as f:
367+
list_manifest = json.load(f)
368+
with open('tests/test_data/manifest.list.v2-image.json') as f:
369+
list_image_manifest = json.load(f)
370+
with open('tests/test_data/origin_vulnerabilities_list.json') as f:
371+
list_origin_data = json.load(f)
372+
responses.add(responses.GET, '%s/%s?features&vulnerabilities' %
373+
(self.v1_analyze_url, list_origin_data['Layer']['Name']),
374+
json=list_origin_data)
375+
responses.replace(responses.GET, self.manifest_url,
376+
json=list_manifest, status=200)
377+
responses.add(responses.GET, list_image_manifest_url,
378+
json=list_image_manifest, status=200)
379+
layers = [e['digest'] for e in list_image_manifest['layers']]
380+
responses.add(responses.DELETE, '%s/%s' %
381+
(self.v1_analyze_url, layers[0]))
382+
for layer in layers:
383+
responses.add(responses.GET, '%s/%s' %
384+
(self.v1_analyze_url, layer))
385+
with patch('sys.argv', ['claircli', '-d', '-c',
386+
self.clair_url, self.name]):
387+
cli = ClairCli()
388+
cli.run()
389+
image = Image(self.name)
390+
self.assert_called_with_url()
391+
for index, layer in enumerate(image.images[0].layers, start=5):
392+
self.assertEqual(
393+
responses.calls[index].request.url, self.v1_analyze_url)
394+
req_body = json.loads(responses.calls[index].request.body)
395+
self.assertEqual(req_body['Layer']['Name'], layer)
396+
self.html = Report.get_report_path('{}/{}@{}'.format(self.reg, self.repo, image.manifest['manifests'][0]['digest']), '.html')
397+
self.assertTrue(isfile(self.html))
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"schemaVersion": 2,
3+
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
4+
"config": {
5+
"mediaType": "application/vnd.docker.container.image.v1+json",
6+
"size": 3689,
7+
"digest": "sha256:79e85ca394595673010a283b31e2f237a046c987ca15f15fb51bb23a8b98cce7"
8+
},
9+
"layers": [
10+
{
11+
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
12+
"size": 73903098,
13+
"digest": "sha256:b565332d1d45150165f73227d2ec4e6ef0127e7b55fac73a5131468b25bc4bfb"
14+
},
15+
{
16+
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
17+
"size": 1412,
18+
"digest": "sha256:04ef0e69dcba4088b009d14d21e86cda00ddbf8e84a4d9746c5d8ec9d61803af"
19+
},
20+
{
21+
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
22+
"size": 15199242,
23+
"digest": "sha256:debe3b1a97504955e5ff561633738d979a1e58861c3dffda3bd9dd59bf7b4d50"
24+
}
25+
]
26+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"manifests": [
3+
{
4+
"digest": "sha256:d0fec089e611891a03f3282f10115bb186ed46093c3f083eceb250cee64b63eb",
5+
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
6+
"platform": {
7+
"architecture": "amd64",
8+
"os": "linux"
9+
},
10+
"size": 949
11+
}
12+
],
13+
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
14+
"schemaVersion": 2
15+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"schemaVersion": 2,
3+
"mediaType": "application/vnd.docker.distribution.manifest.unsupported+json",
4+
"config": {
5+
"mediaType": "application/vnd.docker.container.image.v1+json",
6+
"size": 3689,
7+
"digest": "sha256:79e85ca394595673010a283b31e2f237a046c987ca15f15fb51bb23a8b98cce7"
8+
},
9+
"layers": [
10+
{
11+
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
12+
"size": 73903098,
13+
"digest": "sha256:b565332d1d45150165f73227d2ec4e6ef0127e7b55fac73a5131468b25bc4bfb"
14+
},
15+
{
16+
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
17+
"size": 1412,
18+
"digest": "sha256:04ef0e69dcba4088b009d14d21e86cda00ddbf8e84a4d9746c5d8ec9d61803af"
19+
},
20+
{
21+
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
22+
"size": 15199242,
23+
"digest": "sha256:debe3b1a97504955e5ff561633738d979a1e58861c3dffda3bd9dd59bf7b4d50"
24+
}
25+
]
26+
}

0 commit comments

Comments
 (0)