Skip to content

Commit

Permalink
feat: add kaniko archive scan support
Browse files Browse the repository at this point in the history
  • Loading branch information
aarlaud committed Feb 1, 2025
1 parent f537e7c commit cd42b08
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 11 deletions.
10 changes: 10 additions & 0 deletions lib/extractor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { AutoDetectedUserInstructions, ImageType } from "../types";
import { PluginOptions } from "../types";
import * as dockerExtractor from "./docker-archive";
import * as kanikoExtractor from "./kaniko-archive";
import * as ociExtractor from "./oci-archive";
import {
DockerArchiveManifest,
Expand Down Expand Up @@ -101,6 +102,15 @@ export async function extractImageContent(
options,
),
],
[
ImageType.KanikoArchive,
new ArchiveExtractor(
kanikoExtractor as unknown as Extractor,
fileSystemPath,
extractActions,
options,
),
],
]);

let extractor: ArchiveExtractor;
Expand Down
24 changes: 24 additions & 0 deletions lib/extractor/kaniko-archive/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { normalize as normalizePath } from "path";
import { HashAlgorithm } from "../../types";

import { KanikoArchiveManifest } from "../types";
export { extractArchive } from "./layer";

export function getManifestLayers(manifest: KanikoArchiveManifest) {
return manifest.Layers.map((layer) => normalizePath(layer));
}

export function getImageIdFromManifest(
manifest: KanikoArchiveManifest,
): string {
try {
const imageId = manifest.Config;
if (imageId.includes(":")) {
// imageId includes the algorithm prefix
return imageId;
}
return `${HashAlgorithm.Sha256}:${imageId}`;
} catch (err) {
throw new Error("Failed to extract image ID from archive manifest");
}
}
133 changes: 133 additions & 0 deletions lib/extractor/kaniko-archive/layer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import * as Debug from "debug";
import { createReadStream } from "fs";
import * as gunzip from "gunzip-maybe";
import { basename, normalize as normalizePath } from "path";
import { Readable } from "stream";
import { extract, Extract } from "tar-stream";
import { InvalidArchiveError } from "..";
import { streamToJson } from "../../stream-utils";
import { PluginOptions } from "../../types";
import { extractImageLayer } from "../layer";
import {
ExtractAction,
ImageConfig,
KanikoArchiveManifest,
KanikoExtractedLayers,
KanikoExtractedLayersAndManifest,
} from "../types";

const debug = Debug("snyk");

/**
* Retrieve the products of files content from the specified kaniko-archive.
* @param kanikoArchiveFilesystemPath Path to image file saved in kaniko-archive format.
* @param extractActions Array of pattern-callbacks pairs.
* @param options PluginOptions
* @returns Array of extracted files products sorted by the reverse order of the layers from last to first.
*/
export async function extractArchive(
kanikoArchiveFilesystemPath: string,
extractActions: ExtractAction[],
_options: Partial<PluginOptions>,
): Promise<KanikoExtractedLayersAndManifest> {
return new Promise((resolve, reject) => {
const tarExtractor: Extract = extract();
const layers: Record<string, KanikoExtractedLayers> = {};
let manifest: KanikoArchiveManifest;
let imageConfig: ImageConfig;

tarExtractor.on("entry", async (header, stream, next) => {
if (header.type === "file") {
const normalizedName = normalizePath(header.name);
if (isTarGzFile(normalizedName)) {
try {
layers[normalizedName] = await extractImageLayer(
stream,
extractActions,
);
} catch (error) {
debug(`Error extracting layer content from: '${error.message}'`);
reject(new Error("Error reading tar.gz archive"));
}
} else if (isManifestFile(normalizedName)) {
const manifestArray = await getManifestFile<KanikoArchiveManifest[]>(
stream,
);

manifest = manifestArray[0];
} else if (isImageConfigFile(normalizedName)) {
imageConfig = await getManifestFile<ImageConfig>(stream);
}
}

stream.resume(); // auto drain the stream
next(); // ready for next entry
});

tarExtractor.on("finish", () => {
try {
resolve(
getLayersContentAndArchiveManifest(manifest, imageConfig, layers),
);
} catch (error) {
debug(
`Error getting layers and manifest content from Kaniko archive: ${error.message}`,
);
reject(new InvalidArchiveError("Invalid Kaniko archive"));
}
});

tarExtractor.on("error", (error) => reject(error));

createReadStream(kanikoArchiveFilesystemPath)
.pipe(gunzip())
.pipe(tarExtractor);
});
}

function getLayersContentAndArchiveManifest(
manifest: KanikoArchiveManifest,
imageConfig: ImageConfig,
layers: Record<string, KanikoExtractedLayers>,
): KanikoExtractedLayersAndManifest {
// skip (ignore) non-existent layers
// get the layers content without the name
// reverse layers order from last to first
const layersWithNormalizedNames = manifest.Layers.map((layersName) =>
normalizePath(layersName),
);
const filteredLayers = layersWithNormalizedNames
.filter((layersName) => layers[layersName])
.map((layerName) => layers[layerName])
.reverse();

if (filteredLayers.length === 0) {
throw new Error("We found no layers in the provided image");
}

return {
layers: filteredLayers,
manifest,
imageConfig,
};
}

/**
* Note: consumes the stream.
*/
async function getManifestFile<T>(stream: Readable): Promise<T> {
return streamToJson<T>(stream);
}

function isManifestFile(name: string): boolean {
return name === "manifest.json";
}

function isImageConfigFile(name: string): boolean {
const configRegex = new RegExp("sha256:[A-Fa-f0-9]{64}");
return configRegex.test(name);
}

function isTarGzFile(name: string): boolean {
return basename(name).endsWith(".tar.gz");
}
29 changes: 29 additions & 0 deletions lib/extractor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ export interface OciImageIndex {
manifests: OciManifestInfo[];
}

export interface KanikoArchiveManifest {
// Usually points to the JSON file in the archive that describes how the image was built.
Config: string;
RepoTags: string[];
// The names of the layers in this archive, usually in the format "<sha256>.tar" or "<sha256>/layer.tar".
Layers: string[];
}

export interface KanikoExtractionResult {
imageId: string;
manifestLayers: string[];
extractedLayers: KanikoExtractedLayers;
rootFsLayers?: string[];
autoDetectedUserInstructions?: AutoDetectedUserInstructions;
platform?: string;
imageLabels?: { [key: string]: string };
imageCreationTime?: string;
}

export interface KanikoExtractedLayers {
[layerName: string]: FileNameAndContent;
}

export interface KanikoExtractedLayersAndManifest {
layers: KanikoExtractedLayers[];
manifest: KanikoArchiveManifest;
imageConfig: ImageConfig;
}

export interface ExtractAction {
// This name should be unique across all actions used.
actionName: string;
Expand Down
25 changes: 15 additions & 10 deletions lib/image-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,27 @@ export function getImageType(targetImage: string): ImageType {
case "oci-archive":
return ImageType.OciArchive;

case "kaniko-archive":
return ImageType.KanikoArchive;

default:
return ImageType.Identifier;
}
}

export function getArchivePath(targetImage: string): string {
if (
!targetImage.startsWith("docker-archive:") &&
!targetImage.startsWith("oci-archive:")
) {
throw new Error(
'The provided archive path is missing a prefix, for example "docker-archive:" or "oci-archive:"',
);
const possibleArchiveTypes = [
"docker-archive",
"oci-archive",
"kaniko-archive",
];
for (const archiveType of possibleArchiveTypes) {
if (targetImage.startsWith(archiveType)) {
return normalizePath(targetImage.substring(`${archiveType}:`.length));
}
}

return targetImage.indexOf("docker-archive:") !== -1
? normalizePath(targetImage.substring("docker-archive:".length))
: normalizePath(targetImage.substring("oci-archive:".length));
throw new Error(
'The provided archive path is missing a prefix, for example "docker-archive:", "oci-archive:" or "kaniko-archive"',
);
}
1 change: 1 addition & 0 deletions lib/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export async function scan(
switch (imageType) {
case ImageType.DockerArchive:
case ImageType.OciArchive:
case ImageType.KanikoArchive:
return localArchiveAnalysis(
targetImage,
imageType,
Expand Down
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum ImageType {
Identifier, // e.g. "nginx:latest"
DockerArchive = "docker-archive", // e.g. "docker-archive:/tmp/nginx.tar"
OciArchive = "oci-archive", // e.g. "oci-archive:/tmp/nginx.tar"
KanikoArchive = "kaniko-archive",
}

export enum OsReleaseFilePath {
Expand Down
Binary file not shown.
21 changes: 21 additions & 0 deletions test/lib/extractor/extractor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,27 @@ describe("extractImageContent", () => {
});
});

describe("Kaniko Image Archives (expected to fall back to DockerExtractor)", () => {
const fixture = getFixture("kaniko-archives/broker-base-kaniko.tar");
const opts = { platform: "linux/amd64" };

it("successfully extracts the archive when image type is set to kaniko-archive", async () => {
await expect(
extractImageContent(ImageType.KanikoArchive, fixture, [], opts),
).resolves.not.toThrow();
});

it("fails to extract the archive when image type is not set", async () => {
await expect(extractImageContent(0, fixture, [], opts)).rejects.toThrow();
});

it("fails to extract the archive when image type is set to docker-archive", async () => {
await expect(
extractImageContent(ImageType.DockerArchive, fixture, [], opts),
).rejects.toThrow();
});
});

describe("Images pulled & saved with Docker Engine >= 25.x", () => {
const type = ImageType.OciArchive;

Expand Down
11 changes: 10 additions & 1 deletion test/lib/image-type.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ describe("image-type", () => {

expect(result).toEqual(expectedImageType);
});

test("should return oci-archive type given oci-archive image", () => {
const image = "kaniko-archive:/tmp/nginx.tar";
const expectedImageType = ImageType.KanikoArchive;

const result = getImageType(image);

expect(result).toEqual(expectedImageType);
});
});

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

expect(() => {
getArchivePath(targetImage);
Expand Down
Loading

0 comments on commit cd42b08

Please sign in to comment.