Skip to content

Commit 1957e7c

Browse files
Merge pull request #587 from snyk/fix/pulling-by-sha
fix: fallback to tag if digest missing
2 parents 393fa09 + 574ff1f commit 1957e7c

File tree

9 files changed

+155
-32
lines changed

9 files changed

+155
-32
lines changed

src/scanner/images/index.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,33 @@ import * as plugin from 'snyk-docker-plugin';
33

44
import logger = require('../../common/logger');
55
import { pull as skopeoCopy, getDestinationForImage } from './skopeo';
6-
import { IPullableImage } from './types';
6+
import { IPullableImage, IScanImage } from './types';
77
import { IStaticAnalysisOptions, StaticAnalysisImageType, IScanResult, IPluginOptions } from '../types';
88

99
export async function pullImages(images: IPullableImage[]): Promise<IPullableImage[]> {
1010
const pulledImages: IPullableImage[] = [];
1111

1212
for (const image of images) {
13-
const {imageName, fileSystemPath} = image;
13+
const { imageName, imageWithDigest, fileSystemPath } = image;
1414
if (!fileSystemPath) {
1515
continue;
1616
}
1717

1818
try {
19-
await skopeoCopy(imageName, fileSystemPath);
19+
// Scan image by digest if exists, other way fallback tag
20+
const scanId = imageWithDigest ?? imageName;
21+
await skopeoCopy(scanId, fileSystemPath);
2022
pulledImages.push(image);
2123
} catch (error) {
22-
logger.error({error, image: imageName}, 'failed to pull image');
24+
logger.error({error, image: imageWithDigest}, 'failed to pull image');
2325
}
2426
}
2527

2628
return pulledImages;
2729
}
2830

29-
export function getImagesWithFileSystemPath(images: string[]): IPullableImage[] {
30-
return images.map((image) => ({ imageName: image, fileSystemPath: getDestinationForImage(image) }));
31+
export function getImagesWithFileSystemPath(images: IScanImage[]): IPullableImage[] {
32+
return images.map((image) => ({ ...image, fileSystemPath: getDestinationForImage(image.imageName) }));
3133
}
3234

3335
export async function removePulledImages(images: IPullableImage[]): Promise<void> {
@@ -41,15 +43,15 @@ export async function removePulledImages(images: IPullableImage[]): Promise<void
4143
}
4244

4345
// Exported for testing
44-
export function getImageParts(imageWithTag: string) : {imageName: string, imageTag: string} {
46+
export function getImageParts(imageWithTag: string) : {imageName: string, imageTag: string, imageDigest: string} {
4547
// we're matching pattern: <registry:port_number>(optional)/<image_name>(mandatory):<image_tag>(optional)@<tag_identifier>(optional)
4648
// extracted from https://github.com/docker/distribution/blob/master/reference/regexp.go
4749
const regex = /^((?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?(?:(?:\/[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?)+)?)(?::([\w][\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][A-Fa-f0-9]{32,}))?$/ig;
4850
const groups = regex.exec(imageWithTag);
49-
51+
5052
if(!groups){
5153
logger.error({image: imageWithTag}, 'Image with tag is malformed, cannot extract valid parts');
52-
return {imageName: imageWithTag, imageTag: ''};
54+
return { imageName: imageWithTag, imageTag: '', imageDigest: '' };
5355
}
5456

5557
const IMAGE_NAME_GROUP = 1;
@@ -58,8 +60,8 @@ export function getImageParts(imageWithTag: string) : {imageName: string, imageT
5860

5961
return {
6062
imageName: groups[IMAGE_NAME_GROUP],
61-
// prefer tag over digest
62-
imageTag: groups[IMAGE_TAG_GROUP] || groups[IMAGE_DIGEST_GROUP] || '',
63+
imageTag: groups[IMAGE_TAG_GROUP] || '',
64+
imageDigest: groups[IMAGE_DIGEST_GROUP] || '',
6365
};
6466
}
6567

@@ -78,7 +80,7 @@ export async function scanImages(images: IPullableImage[]): Promise<IScanResult[
7880

7981
const dockerfile = undefined;
8082

81-
for (const {imageName, fileSystemPath} of images) {
83+
for (const { imageName, fileSystemPath, imageWithDigest } of images) {
8284
try {
8385
const staticAnalysisOptions = constructStaticAnalysisOptions(fileSystemPath);
8486
const options: IPluginOptions = {
@@ -92,16 +94,19 @@ export async function scanImages(images: IPullableImage[]): Promise<IScanResult[
9294
throw Error('Unexpected empty result from docker-plugin');
9395
}
9496

95-
const imageParts: {imageName: string, imageTag: string} = getImageParts(imageName);
97+
const imageParts = getImageParts(imageName);
98+
const imageDigest = imageWithDigest && getImageParts(imageWithDigest).imageDigest;
9699

97100
result.imageMetadata = {
98101
image: imageParts.imageName,
99102
imageTag: imageParts.imageTag,
103+
imageDigest,
100104
};
101105

102106
scannedImages.push({
103107
image: imageParts.imageName,
104108
imageWithTag: imageName,
109+
imageWithDigest: imageWithDigest,
105110
pluginResult: result,
106111
});
107112
} catch (error) {

src/scanner/images/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
export interface IScanImage {
2+
imageName: string;
3+
imageWithDigest?: string;
4+
}
5+
16
export interface IPullableImage {
27
imageName: string;
8+
imageWithDigest?: string;
39
fileSystemPath: string;
410
}
511

src/scanner/index.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
import logger = require('../common/logger');
2-
import { pullImages, removePulledImages, getImagesWithFileSystemPath, scanImages } from './images';
2+
import { pullImages, removePulledImages, getImagesWithFileSystemPath, scanImages, getImageParts } from './images';
33
import { deleteWorkload, sendDepGraph } from '../transmitter';
44
import { constructDeleteWorkload, constructDepGraph } from '../transmitter/payload';
55
import { IWorkload, ILocalWorkloadLocator } from '../transmitter/types';
6-
import { IPullableImage } from './images/types';
6+
import { IPullableImage, IScanImage } from './images/types';
77

88
export async function processWorkload(workloadMetadata: IWorkload[]): Promise<void> {
99
// every workload metadata references the same workload name, grab it from the first one
1010
const workloadName = workloadMetadata[0].name;
11-
const allImages = workloadMetadata.map((meta) => meta.imageName);
12-
logger.info({workloadName, imageCount: allImages.length}, 'queried workloads');
13-
const uniqueImages = [...new Set<string>(allImages)];
11+
const uniqueImages: IScanImage[] = getUniqueImages(workloadMetadata);
1412

15-
logger.info({workloadName, imageCount: uniqueImages.length}, 'pulling unique images');
13+
logger.info({ workloadName, imageCount: uniqueImages.length }, 'pulling unique images');
1614
const imagesWithFileSystemPath = getImagesWithFileSystemPath(uniqueImages);
1715
const pulledImages = await pullImages(imagesWithFileSystemPath);
1816
if (pulledImages.length === 0) {
19-
logger.info({workloadName}, 'no images were pulled, halting scanner process.');
17+
logger.info({ workloadName }, 'no images were pulled, halting scanner process.');
2018
return;
2119
}
2220

@@ -35,6 +33,38 @@ export async function sendDeleteWorkloadRequest(workloadName: string, localWorkl
3533
await deleteWorkload(deletePayload);
3634
}
3735

36+
export function getUniqueImages(workloadMetadata: IWorkload[]): IScanImage[] {
37+
const uniqueImages: { [key: string]: IScanImage } = workloadMetadata.reduce((accum, meta) => {
38+
logger.info({
39+
workloadName: workloadMetadata[0].name,
40+
name: meta.imageName,
41+
id: meta.imageId
42+
}, 'image metadata');
43+
// example: For DCR "redis:latest"
44+
// example: For GCR "gcr.io/test-dummy/redis:latest"
45+
// example: For ECR "291964488713.dkr.ecr.us-east-2.amazonaws.com/snyk/redis:latest"
46+
// meta.imageName can be different depends on CR
47+
const { imageName } = getImageParts(meta.imageName);
48+
// meta.imageId can be different depends on CR
49+
// example: For DCR "docker.io/library/redis@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6"
50+
// example: For GCR "sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6"
51+
// example: For ECR "sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6"
52+
let digest: string | undefined = undefined;
53+
if (meta.imageId.lastIndexOf('@') > -1 || meta.imageId.startsWith('sha')) {
54+
digest = meta.imageId.substring(meta.imageId.lastIndexOf('@') + 1);
55+
}
56+
57+
accum[meta.imageName] = {
58+
imageWithDigest: digest && `${imageName}@${digest}`,
59+
imageName: meta.imageName, // Image name with tag
60+
};
61+
62+
return accum;
63+
}, {});
64+
65+
return Object.values(uniqueImages);
66+
}
67+
3868
async function scanImagesAndSendResults(
3969
workloadName: string,
4070
pulledImages: IPullableImage[],

src/scanner/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export interface IScanResult {
22
image: string;
3+
imageWithDigest?: string;
34
imageWithTag: string;
45
pluginResult: any;
56
}

src/transmitter/payload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function constructDepGraph(
2727
const imageLocator: IImageLocator = {
2828
userLocator: config.INTEGRATION_ID,
2929
imageId: scannedImage.image,
30+
imageWithDigest: scannedImage.imageWithDigest,
3031
cluster,
3132
namespace,
3233
type,

src/transmitter/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface IWorkloadMetadata {
2525

2626
export interface IImageLocator extends IWorkloadLocator {
2727
imageId: string;
28+
imageWithDigest?: string;
2829
}
2930

3031
export interface IKubernetesMonitorMetadata {

test/unit/scanner/images.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import * as tap from 'tap';
22

3-
import { IPullableImage } from '../../../src/scanner/images/types';
3+
import {IPullableImage, IScanImage} from '../../../src/scanner/images/types';
44
import config = require('../../../src/common/config');
55
import * as scannerImages from '../../../src/scanner/images';
66

7-
87
tap.test('getImagesWithFileSystemPath()', async (t) => {
9-
const noImages: string[] = [];
8+
const noImages: IScanImage[] = [];
109
const noImagesResult = scannerImages.getImagesWithFileSystemPath(noImages);
1110
t.same(noImagesResult, [], 'correctly maps an empty array');
1211

13-
const image = ['nginx:latest'];
12+
const image: IScanImage[] = [{
13+
imageName: 'nginx:latest',
14+
imageWithDigest: 'nginx@sha256:4949aa7259aa6f827450207db5ad94cabaa9248277c6d736d5e1975d200c7e43',
15+
}];
1416
const imageResult = scannerImages.getImagesWithFileSystemPath(image);
1517
t.same(imageResult.length, 1, 'expected 1 item');
1618

@@ -28,7 +30,10 @@ tap.test('getImagesWithFileSystemPath()', async (t) => {
2830
t.ok(expectedPattern, 'the file system path starts with an expected pattern');
2931

3032
// Ensure that two consecutive calls do not return the same file system path
31-
const someImage = ['centos:latest'];
33+
const someImage = [{
34+
imageName: 'centos:latest',
35+
imageWithDigest: 'centos@sha256:fc4a234b91cc4b542bac8a6ad23b2ddcee60ae68fc4dbd4a52efb5f1b0baad71',
36+
}];
3237
const firstCallResult = scannerImages.getImagesWithFileSystemPath(someImage)[0];
3338
const secondCallResult = scannerImages.getImagesWithFileSystemPath(someImage)[0];
3439
t.ok(
@@ -44,8 +49,6 @@ tap.test('pullImages() skips on missing file system path', async (t) => {
4449
});
4550

4651
tap.test('constructStaticAnalysisOptions() tests', async (t) => {
47-
t.plan(1);
48-
4952
const somePath = '/var/tmp/file.tar';
5053
const options = scannerImages.constructStaticAnalysisOptions(somePath);
5154
const expectedResult = {
@@ -57,11 +60,10 @@ tap.test('constructStaticAnalysisOptions() tests', async (t) => {
5760
});
5861

5962
tap.test('extracted image tag tests', async (t) => {
60-
t.plan(6);
61-
6263
const imageWithSha = 'nginx@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2';
6364
const imageWithShaResult = scannerImages.getImageParts(imageWithSha);
64-
t.same(imageWithShaResult.imageTag, 'sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2', 'image sha is returned');
65+
t.same(imageWithShaResult.imageTag, '', 'image tag is empty');
66+
t.same(imageWithShaResult.imageDigest, 'sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2', 'image digest is returned');
6567

6668
const imageWithTag = 'nginx:latest';
6769
const imageWithTagResult = scannerImages.getImageParts(imageWithTag);
@@ -85,8 +87,6 @@ tap.test('extracted image tag tests', async (t) => {
8587
});
8688

8789
tap.test('extracted image name tests', async (t) => {
88-
t.plan(5);
89-
9090
t.same(scannerImages.getImageParts('nginx:latest').imageName, 'nginx', 'removed image:tag');
9191
t.same(scannerImages.getImageParts('node@sha256:215a9fbef4df2c1ceb7c79481d3cfd94ad8f1f0105bade39f3be907bf386c5e1').imageName, 'node', 'removed image@sha:hex');
9292
t.same(scannerImages.getImageParts('kind-registry:5000/python:rc-buster').imageName, 'kind-registry:5000/python', 'removed repository/image:tag');

test/unit/scanner/index.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as tap from 'tap';
2+
3+
import * as scanner from '../../../src/scanner';
4+
import { IWorkload } from '../../../src/transmitter/types';
5+
6+
tap.test('getUniqueImages()', async (t) => {
7+
const workload: Partial<IWorkload>[] = [
8+
// 1.DCR
9+
{
10+
imageName: 'redis:latest',
11+
imageId: 'docker.io/library/redis@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'
12+
},
13+
// 2.Duplicate to verify uniqueness
14+
{
15+
imageName: 'redis:latest',
16+
imageId: 'docker.io/library/redis@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'
17+
},
18+
// 3. With SHA instead of tag
19+
{
20+
imageName: 'redis@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6',
21+
imageId: 'docker.io/library/redis@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'
22+
},
23+
// 4. With SHA missing in imageId
24+
{
25+
imageName: 'redis:prod',
26+
imageId: 'docker.io/library/redis:eaa6f054e4a140bc3a1696cc7b1e84529e7e9567',
27+
},
28+
// 5. GCR
29+
{
30+
imageName: 'gcr.io/test-dummy/redis:latest',
31+
imageId: 'sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'
32+
},
33+
// 6. ECR
34+
{
35+
imageName: '291964488713.dkr.ecr.us-east-2.amazonaws.com/snyk/redis:latest',
36+
imageId: 'sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'
37+
},
38+
// 7. With docker-pullable as protocol in imageId
39+
{
40+
imageName: 'redis:some-tag',
41+
imageId: 'docker-pullable://name@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6',
42+
},
43+
// 8. With docker as protocol in imageId
44+
{
45+
imageName: 'redis:another-tag',
46+
imageId: 'docker://name@sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'
47+
}
48+
];
49+
50+
const result = scanner.getUniqueImages(workload as any);
51+
52+
t.strictEqual(result.length, 7, 'removed duplicate image');
53+
const resultWithDigest = result.filter(({imageWithDigest}) => imageWithDigest);
54+
const resultWithoutDigest = result.filter(({imageWithDigest}) => !imageWithDigest);
55+
56+
t.strictEqual(resultWithDigest.length, 6, 'correct amount with digest');
57+
t.strictEqual(resultWithoutDigest.length, 1, 'correct amount without digest');
58+
59+
resultWithDigest.map((metaData) => {
60+
t.ok(metaData.imageWithDigest!.includes('redis'), 'has name in imageWithDigest');
61+
t.ok(metaData.imageWithDigest!.includes('sha256:8e9f8546050da8aae393a41d65ad37166b4f0d8131d627a520c0f0451742e9d6'), 'has digest');
62+
63+
if (metaData.imageWithDigest!.includes('gcr')) {
64+
t.ok(metaData.imageWithDigest!.includes('/'), 'contains / in GCR imageWithDigest');
65+
}
66+
67+
if (metaData.imageWithDigest!.includes('ecr')) {
68+
t.ok(metaData.imageWithDigest!.includes('/'), 'contains / in ECR imageWithDigest');
69+
}
70+
});
71+
72+
resultWithoutDigest.map((metadata) => {
73+
t.ok(metadata.imageName.includes('redis'));
74+
t.notOk(metadata.imageWithDigest);
75+
});
76+
});

test/unit/transmitter-payload.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ tap.test('constructDepGraph breaks when workloadMetadata is missing items', asyn
1111
{
1212
image: 'myImage',
1313
imageWithTag: 'myImage:tag',
14+
imageWithDigest: 'myImage@sha256:idontcarewhatissha',
1415
pluginResult: 'whatever1',
1516
},
1617
{
1718
image: 'anotherImage',
1819
imageWithTag: 'anotherImage:1.2.3-alpha',
20+
imageWithDigest: 'myImage@sha256:somuchdifferentsha256',
1921
pluginResult: 'whatever3',
2022
},
2123
];
@@ -48,6 +50,7 @@ tap.test('constructDepGraph happy flow', async (t) => {
4850
{
4951
image: 'myImage',
5052
imageWithTag: 'myImage:tag',
53+
imageWithDigest: 'myImage@sha256:idontcarewhatissha',
5154
pluginResult: 'whatever1',
5255
},
5356
];

0 commit comments

Comments
 (0)