Skip to content

Commit

Permalink
feat: experimental support for Distroless images
Browse files Browse the repository at this point in the history
- requires the experimental flag to be turned on
- assumption #1: the image is present in the local Docker daemon
- assumption #2: the "docker" binary is available to use
- saves the image as an archive, statically scans it using new capabilities that check each individual file meeting the APT standards, collecting it to the result and cleaning the archive
- tested with distroless base debian 9 and 10
  • Loading branch information
Shesekino committed Mar 20, 2020
1 parent 7b84827 commit 7159cae
Show file tree
Hide file tree
Showing 10 changed files with 229 additions and 27 deletions.
18 changes: 18 additions & 0 deletions lib/analyzer/package-managers/apt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ export function analyze(
});
}

export function analyzeDistroless(
targetImage: string,
aptFiles: string[],
): Promise<ImageAnalysis> {
const analyzedPackages: AnalyzedPackage[] = [];

for (const fileContent of aptFiles) {
const currentPackages = parseDpkgFile(fileContent);
analyzedPackages.push(...currentPackages);
}

return Promise.resolve({
Image: targetImage,
AnalyzeType: AnalysisType.Distroless,
Analysis: analyzedPackages,
});
}

function parseDpkgFile(text: string): AnalyzedPackage[] {
const pkgs: AnalyzedPackage[] = [];
let curPkg: any = null;
Expand Down
19 changes: 18 additions & 1 deletion lib/analyzer/static-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
getBinariesHashes,
getNodeBinariesFileContentAction,
} from "../inputs/binaries/static";
import {
getAptFiles,
getDpkgPackageFileContentAction,
} from "../inputs/distroless/static";
import { getOsReleaseActions } from "../inputs/os-release/static";
import {
getRpmDbFileContent,
Expand All @@ -21,7 +25,10 @@ import {
import { ImageType, StaticAnalysisOptions } from "../types";
import * as osReleaseDetector from "./os-release";
import { analyze as apkAnalyze } from "./package-managers/apk";
import { analyze as aptAnalyze } from "./package-managers/apt";
import {
analyze as aptAnalyze,
analyzeDistroless as aptDistrolessAnalyze,
} from "./package-managers/apt";
import { analyze as rpmAnalyze } from "./package-managers/rpm";
import { ImageAnalysis, OSRelease, StaticAnalysis } from "./types";

Expand All @@ -44,6 +51,10 @@ export async function analyze(
getNodeBinariesFileContentAction,
];

if (options.distroless) {
staticAnalysisActions.push(getDpkgPackageFileContentAction);
}

const dockerArchive = await getDockerArchiveLayersAndManifest(
options.imagePath,
staticAnalysisActions,
Expand All @@ -61,6 +72,11 @@ export async function analyze(
getRpmDbFileContent(archiveLayers, options.tmpDirPath),
]);

let distrolessAptFiles: string[] = [];
if (options.distroless) {
distrolessAptFiles = getAptFiles(archiveLayers);
}

let osRelease: OSRelease;
try {
osRelease = await osReleaseDetector.detectStatically(archiveLayers);
Expand All @@ -75,6 +91,7 @@ export async function analyze(
apkAnalyze(targetImage, apkDbFileContent),
aptAnalyze(targetImage, aptDbFileContent),
rpmAnalyze(targetImage, rpmDbFileContent),
aptDistrolessAnalyze(targetImage, distrolessAptFiles),
]);
} catch (err) {
debug(err);
Expand Down
1 change: 1 addition & 0 deletions lib/analyzer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export enum AnalysisType {
Rpm = "Rpm",
Binaries = "binaries",
Linux = "linux", // default/unknown/tech-debt
Distroless = "distroless",
}

export interface OSRelease {
Expand Down
51 changes: 45 additions & 6 deletions lib/experimental.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,54 @@
import { PluginResponse } from "./types";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import { Docker } from "./docker";
import * as staticModule from "./static";
import { ImageType, PluginResponse } from "./types";

export async function experimentalAnalysis(
options: any,
targetImage: string,
): Promise<PluginResponse> {
// assume Distroless scanning
return distroless(options);
return distroless(targetImage);
}

// experimental flow expected to be merged with the static analysis when ready
export async function distroless(options: any): Promise<PluginResponse> {
// assumption #1: the image is present in the local Docker daemon
export async function distroless(targetImage: string): Promise<PluginResponse> {
const archiveDir = path.join(os.tmpdir(), "snyk-image-archives");
createTempDirIfMissing(archiveDir);
// TODO terrible way to convert slashes to anything else
// so we don't think it's a directory
const archiveFileName = `${targetImage.replace(/\//g, "__")}.tar`;
const archiveFullPath = path.join(archiveDir, archiveFileName);

// assumption #2: the `docker` binary is available locally
throw new Error("not implemented");
const docker = new Docker(targetImage);
// assumption #1: the image is present in the local Docker daemon
await docker.save(targetImage, archiveFullPath);
try {
const scanningOptions = {
staticAnalysisOptions: {
imagePath: archiveFullPath,
imageType: ImageType.DockerArchive,
// TODO only for RPM, may be removed once we get rid of bdb dep
tmpDirPath: "",
distroless: true,
},
};

return staticModule.analyzeStatically(targetImage, scanningOptions);
} finally {
fs.unlinkSync(archiveFullPath);
}
}

function createTempDirIfMissing(archiveDir: string): void {
try {
fs.mkdirSync(archiveDir);
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
}
}
2 changes: 1 addition & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function inspect(
const targetImage = root;

if (options && options.experimental) {
return experimentalAnalysis(options);
return experimentalAnalysis(targetImage);
}

if (staticUtil.isRequestingStaticAnalysis(options)) {
Expand Down
21 changes: 21 additions & 0 deletions lib/inputs/distroless/static.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ExtractAction, ExtractedLayers } from "../../extractor/types";
import { streamToString } from "../../stream-utils";

export const getDpkgPackageFileContentAction: ExtractAction = {
actionName: "dpkg",
fileNamePattern: "/var/lib/dpkg/status.d/*",
callback: streamToString, // TODO replace with a parser for apt data extractor
};

export function getAptFiles(extractedLayers: ExtractedLayers): string[] {
const files: string[] = [];

for (const fileName of Object.keys(extractedLayers)) {
if (!("dpkg" in extractedLayers[fileName])) {
continue;
}
files.push(extractedLayers[fileName].dpkg.toString("utf8"));
}

return files;
}
1 change: 1 addition & 0 deletions lib/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,6 @@ function getStaticAnalysisOptions(options: any): StaticAnalysisOptions {
imagePath: options.staticAnalysisOptions.imagePath,
imageType: options.staticAnalysisOptions.imageType,
tmpDirPath: options.staticAnalysisOptions.tmpDirPath,
distroless: options.staticAnalysisOptions.distroless,
};
}
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface StaticAnalysisOptions {
* If unspecified, defaults to the environment's temporary directory path.
*/
tmpDirPath?: string;
distroless: boolean;
}

export enum ImageType {
Expand Down
19 changes: 0 additions & 19 deletions test/system/experimental.test.ts

This file was deleted.

123 changes: 123 additions & 0 deletions test/system/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as path from "path";
import { test } from "tap";

import * as plugin from "../../lib";
import * as subProcess from "../../lib/sub-process";
import { ImageType, PluginResponseStatic } from "../../lib/types";

const getFixture = (fixturePath) =>
Expand Down Expand Up @@ -222,3 +223,125 @@ test("static analysis works for scratch images", async (t) => {
"operating system for scratch image is unknown",
);
});

test("static analysis for distroless base-debian9", async (t) => {
// 70b8c7f2d41a844d310c23e0695388c916a364ed was "latest" at the time of writing
const imageNameAndTag =
"gcr.io/distroless/base-debian9:70b8c7f2d41a844d310c23e0695388c916a364ed";

// distroless assumption #1: image is present in local daemon
await subProcess.execute("docker", ["image", "pull", imageNameAndTag]);

const dockerfile = undefined;
const pluginOptions = {
experimental: true,
};

const pluginResult = await plugin.inspect(
imageNameAndTag,
dockerfile,
pluginOptions,
);

const expectedDependencies = {
"glibc/libc6": { name: "glibc/libc6", version: "2.24-11+deb9u4" },
"openssl/libssl1.1": {
name: "openssl/libssl1.1",
version: "1.1.0l-1~deb9u1",
dependencies: {
"glibc/libc6": { name: "glibc/libc6", version: "2.24-11+deb9u4" },
},
},
openssl: {
name: "openssl",
version: "1.1.0l-1~deb9u1",
dependencies: {
"glibc/libc6": { name: "glibc/libc6", version: "2.24-11+deb9u4" },
"openssl/libssl1.1": {
name: "openssl/libssl1.1",
version: "1.1.0l-1~deb9u1",
},
},
},
"base-files": { name: "base-files", version: "9.9+deb9u12" },
netbase: { name: "netbase", version: "5.4" },
tzdata: { name: "tzdata", version: "2019c-0+deb9u1" },
};

t.ok("package" in pluginResult, "plugin result has packages");

t.ok("dependencies" in pluginResult.package, "packages have dependencies");
t.deepEquals(
pluginResult.package.dependencies,
expectedDependencies,
"Distroless base image dependencies are correct",
);

t.ok("targetOS" in pluginResult.package, "OS discovered");
t.deepEquals(
pluginResult.package.targetOS,
{ name: "debian", version: "9" },
"recognised it's debian 9",
);
});

test("static analysis for distroless base-debian10", async (t) => {
// 70b8c7f2d41a844d310c23e0695388c916a364ed was "latest" at the time of writing
const imageNameAndTag =
"gcr.io/distroless/base-debian10:70b8c7f2d41a844d310c23e0695388c916a364ed";

// distroless assumption #1: image is present in local daemon
await subProcess.execute("docker", ["image", "pull", imageNameAndTag]);

const dockerfile = undefined;
const pluginOptions = {
experimental: true,
};

const pluginResult = await plugin.inspect(
imageNameAndTag,
dockerfile,
pluginOptions,
);

const expectedDependencies = {
"glibc/libc6": { name: "glibc/libc6", version: "2.28-10" },
"openssl/libssl1.1": {
name: "openssl/libssl1.1",
version: "1.1.1d-0+deb10u2",
dependencies: {
"glibc/libc6": { name: "glibc/libc6", version: "2.28-10" },
},
},
openssl: {
name: "openssl",
version: "1.1.1d-0+deb10u2",
dependencies: {
"glibc/libc6": { name: "glibc/libc6", version: "2.28-10" },
"openssl/libssl1.1": {
name: "openssl/libssl1.1",
version: "1.1.1d-0+deb10u2",
},
},
},
"base-files": { name: "base-files", version: "10.3+deb10u3" },
netbase: { name: "netbase", version: "5.6" },
tzdata: { name: "tzdata", version: "2019c-0+deb10u1" },
};

t.ok("package" in pluginResult, "plugin result has packages");

t.ok("dependencies" in pluginResult.package, "packages have dependencies");
t.deepEquals(
pluginResult.package.dependencies,
expectedDependencies,
"Distroless base image dependencies are correct",
);

t.ok("targetOS" in pluginResult.package, "OS discovered");
t.deepEquals(
pluginResult.package.targetOS,
{ name: "debian", version: "10" },
"recognised it's debian 10",
);
});

0 comments on commit 7159cae

Please sign in to comment.