Skip to content

Commit 649a58a

Browse files
authored
feat: add kaniko archive scan support (#642)
* feat: add kaniko archive scan support
1 parent f537e7c commit 649a58a

File tree

12 files changed

+333
-11
lines changed

12 files changed

+333
-11
lines changed

lib/extractor/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { AutoDetectedUserInstructions, ImageType } from "../types";
77
import { PluginOptions } from "../types";
88
import * as dockerExtractor from "./docker-archive";
9+
import * as kanikoExtractor from "./kaniko-archive";
910
import * as ociExtractor from "./oci-archive";
1011
import {
1112
DockerArchiveManifest,
@@ -101,6 +102,15 @@ export async function extractImageContent(
101102
options,
102103
),
103104
],
105+
[
106+
ImageType.KanikoArchive,
107+
new ArchiveExtractor(
108+
kanikoExtractor as unknown as Extractor,
109+
fileSystemPath,
110+
extractActions,
111+
options,
112+
),
113+
],
104114
]);
105115

106116
let extractor: ArchiveExtractor;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { normalize as normalizePath } from "path";
2+
import { HashAlgorithm } from "../../types";
3+
4+
import { KanikoArchiveManifest } from "../types";
5+
export { extractArchive } from "./layer";
6+
7+
export function getManifestLayers(manifest: KanikoArchiveManifest) {
8+
return manifest.Layers.map((layer) => normalizePath(layer));
9+
}
10+
11+
export function getImageIdFromManifest(
12+
manifest: KanikoArchiveManifest,
13+
): string {
14+
try {
15+
const imageId = manifest.Config;
16+
if (imageId.includes(":")) {
17+
// imageId includes the algorithm prefix
18+
return imageId;
19+
}
20+
return `${HashAlgorithm.Sha256}:${imageId}`;
21+
} catch (err) {
22+
throw new Error("Failed to extract image ID from archive manifest");
23+
}
24+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import * as Debug from "debug";
2+
import { createReadStream } from "fs";
3+
import * as gunzip from "gunzip-maybe";
4+
import { basename, normalize as normalizePath } from "path";
5+
import { Readable } from "stream";
6+
import { extract, Extract } from "tar-stream";
7+
import { InvalidArchiveError } from "..";
8+
import { streamToJson } from "../../stream-utils";
9+
import { PluginOptions } from "../../types";
10+
import { extractImageLayer } from "../layer";
11+
import {
12+
ExtractAction,
13+
ImageConfig,
14+
KanikoArchiveManifest,
15+
KanikoExtractedLayers,
16+
KanikoExtractedLayersAndManifest,
17+
} from "../types";
18+
19+
const debug = Debug("snyk");
20+
21+
/**
22+
* Retrieve the products of files content from the specified kaniko-archive.
23+
* @param kanikoArchiveFilesystemPath Path to image file saved in kaniko-archive format.
24+
* @param extractActions Array of pattern-callbacks pairs.
25+
* @param options PluginOptions
26+
* @returns Array of extracted files products sorted by the reverse order of the layers from last to first.
27+
*/
28+
export async function extractArchive(
29+
kanikoArchiveFilesystemPath: string,
30+
extractActions: ExtractAction[],
31+
_options: Partial<PluginOptions>,
32+
): Promise<KanikoExtractedLayersAndManifest> {
33+
return new Promise((resolve, reject) => {
34+
const tarExtractor: Extract = extract();
35+
const layers: Record<string, KanikoExtractedLayers> = {};
36+
let manifest: KanikoArchiveManifest;
37+
let imageConfig: ImageConfig;
38+
39+
tarExtractor.on("entry", async (header, stream, next) => {
40+
if (header.type === "file") {
41+
const normalizedName = normalizePath(header.name);
42+
if (isTarGzFile(normalizedName)) {
43+
try {
44+
layers[normalizedName] = await extractImageLayer(
45+
stream,
46+
extractActions,
47+
);
48+
} catch (error) {
49+
debug(`Error extracting layer content from: '${error.message}'`);
50+
reject(new Error("Error reading tar.gz archive"));
51+
}
52+
} else if (isManifestFile(normalizedName)) {
53+
const manifestArray = await getManifestFile<KanikoArchiveManifest[]>(
54+
stream,
55+
);
56+
57+
manifest = manifestArray[0];
58+
} else if (isImageConfigFile(normalizedName)) {
59+
imageConfig = await getManifestFile<ImageConfig>(stream);
60+
}
61+
}
62+
63+
stream.resume(); // auto drain the stream
64+
next(); // ready for next entry
65+
});
66+
67+
tarExtractor.on("finish", () => {
68+
try {
69+
resolve(
70+
getLayersContentAndArchiveManifest(manifest, imageConfig, layers),
71+
);
72+
} catch (error) {
73+
debug(
74+
`Error getting layers and manifest content from Kaniko archive: ${error.message}`,
75+
);
76+
reject(new InvalidArchiveError("Invalid Kaniko archive"));
77+
}
78+
});
79+
80+
tarExtractor.on("error", (error) => reject(error));
81+
82+
createReadStream(kanikoArchiveFilesystemPath)
83+
.pipe(gunzip())
84+
.pipe(tarExtractor);
85+
});
86+
}
87+
88+
function getLayersContentAndArchiveManifest(
89+
manifest: KanikoArchiveManifest,
90+
imageConfig: ImageConfig,
91+
layers: Record<string, KanikoExtractedLayers>,
92+
): KanikoExtractedLayersAndManifest {
93+
// skip (ignore) non-existent layers
94+
// get the layers content without the name
95+
// reverse layers order from last to first
96+
const layersWithNormalizedNames = manifest.Layers.map((layersName) =>
97+
normalizePath(layersName),
98+
);
99+
const filteredLayers = layersWithNormalizedNames
100+
.filter((layersName) => layers[layersName])
101+
.map((layerName) => layers[layerName])
102+
.reverse();
103+
104+
if (filteredLayers.length === 0) {
105+
throw new Error("We found no layers in the provided image");
106+
}
107+
108+
return {
109+
layers: filteredLayers,
110+
manifest,
111+
imageConfig,
112+
};
113+
}
114+
115+
/**
116+
* Note: consumes the stream.
117+
*/
118+
async function getManifestFile<T>(stream: Readable): Promise<T> {
119+
return streamToJson<T>(stream);
120+
}
121+
122+
function isManifestFile(name: string): boolean {
123+
return name === "manifest.json";
124+
}
125+
126+
function isImageConfigFile(name: string): boolean {
127+
const configRegex = new RegExp("sha256:[A-Fa-f0-9]{64}");
128+
return configRegex.test(name);
129+
}
130+
131+
function isTarGzFile(name: string): boolean {
132+
return basename(name).endsWith(".tar.gz");
133+
}

lib/extractor/types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,35 @@ export interface OciImageIndex {
9090
manifests: OciManifestInfo[];
9191
}
9292

93+
export interface KanikoArchiveManifest {
94+
// Usually points to the JSON file in the archive that describes how the image was built.
95+
Config: string;
96+
RepoTags: string[];
97+
// The names of the layers in this archive, usually in the format "<sha256>.tar" or "<sha256>/layer.tar".
98+
Layers: string[];
99+
}
100+
101+
export interface KanikoExtractionResult {
102+
imageId: string;
103+
manifestLayers: string[];
104+
extractedLayers: KanikoExtractedLayers;
105+
rootFsLayers?: string[];
106+
autoDetectedUserInstructions?: AutoDetectedUserInstructions;
107+
platform?: string;
108+
imageLabels?: { [key: string]: string };
109+
imageCreationTime?: string;
110+
}
111+
112+
export interface KanikoExtractedLayers {
113+
[layerName: string]: FileNameAndContent;
114+
}
115+
116+
export interface KanikoExtractedLayersAndManifest {
117+
layers: KanikoExtractedLayers[];
118+
manifest: KanikoArchiveManifest;
119+
imageConfig: ImageConfig;
120+
}
121+
93122
export interface ExtractAction {
94123
// This name should be unique across all actions used.
95124
actionName: string;

lib/image-type.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,27 @@ export function getImageType(targetImage: string): ImageType {
1010
case "oci-archive":
1111
return ImageType.OciArchive;
1212

13+
case "kaniko-archive":
14+
return ImageType.KanikoArchive;
15+
1316
default:
1417
return ImageType.Identifier;
1518
}
1619
}
1720

1821
export function getArchivePath(targetImage: string): string {
19-
if (
20-
!targetImage.startsWith("docker-archive:") &&
21-
!targetImage.startsWith("oci-archive:")
22-
) {
23-
throw new Error(
24-
'The provided archive path is missing a prefix, for example "docker-archive:" or "oci-archive:"',
25-
);
22+
const possibleArchiveTypes = [
23+
"docker-archive",
24+
"oci-archive",
25+
"kaniko-archive",
26+
];
27+
for (const archiveType of possibleArchiveTypes) {
28+
if (targetImage.startsWith(archiveType)) {
29+
return normalizePath(targetImage.substring(`${archiveType}:`.length));
30+
}
2631
}
2732

28-
return targetImage.indexOf("docker-archive:") !== -1
29-
? normalizePath(targetImage.substring("docker-archive:".length))
30-
: normalizePath(targetImage.substring("oci-archive:".length));
33+
throw new Error(
34+
'The provided archive path is missing a prefix, for example "docker-archive:", "oci-archive:" or "kaniko-archive"',
35+
);
3136
}

lib/scan.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export async function scan(
9494
switch (imageType) {
9595
case ImageType.DockerArchive:
9696
case ImageType.OciArchive:
97+
case ImageType.KanikoArchive:
9798
return localArchiveAnalysis(
9899
targetImage,
99100
imageType,

lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export enum ImageType {
1010
Identifier, // e.g. "nginx:latest"
1111
DockerArchive = "docker-archive", // e.g. "docker-archive:/tmp/nginx.tar"
1212
OciArchive = "oci-archive", // e.g. "oci-archive:/tmp/nginx.tar"
13+
KanikoArchive = "kaniko-archive",
1314
}
1415

1516
export enum OsReleaseFilePath {
4 KB
Binary file not shown.

test/lib/extractor/extractor.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ describe("extractImageContent", () => {
8080
});
8181
});
8282

83+
describe("Kaniko Image Archives", () => {
84+
const fixture = getFixture("kaniko-archives/kaniko-busybox.tar");
85+
const opts = { platform: "linux/amd64" };
86+
87+
it("successfully extracts the archive when image type is set to kaniko-archive", async () => {
88+
await expect(
89+
extractImageContent(ImageType.KanikoArchive, fixture, [], opts),
90+
).resolves.not.toThrow();
91+
});
92+
93+
it("fails to extract the archive when image type is not set", async () => {
94+
await expect(extractImageContent(0, fixture, [], opts)).rejects.toThrow();
95+
});
96+
97+
it("fails to extract the archive when image type is set to docker-archive", async () => {
98+
await expect(
99+
extractImageContent(ImageType.DockerArchive, fixture, [], opts),
100+
).rejects.toThrow();
101+
});
102+
});
103+
83104
describe("Images pulled & saved with Docker Engine >= 25.x", () => {
84105
const type = ImageType.OciArchive;
85106

test/lib/image-type.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ describe("image-type", () => {
2929

3030
expect(result).toEqual(expectedImageType);
3131
});
32+
33+
test("should return kaniko-archive type given kaniko-archive image", () => {
34+
const image = "kaniko-archive:/tmp/nginx.tar";
35+
const expectedImageType = ImageType.KanikoArchive;
36+
37+
const result = getImageType(image);
38+
39+
expect(result).toEqual(expectedImageType);
40+
});
3241
});
3342

3443
describe("getArchivePath", () => {
@@ -53,7 +62,7 @@ describe("image-type", () => {
5362
test("should throws error given bad path provided", () => {
5463
const targetImage = "bad-pathr";
5564
const expectedErrorMessage =
56-
'The provided archive path is missing a prefix, for example "docker-archive:" or "oci-archive:"';
65+
'The provided archive path is missing a prefix, for example "docker-archive:", "oci-archive:" or "kaniko-archive"';
5766

5867
expect(() => {
5968
getArchivePath(targetImage);

0 commit comments

Comments
 (0)