Skip to content

Commit e9a6a45

Browse files
authored
feat: add support for Ubuntu Chisel package scanning (#718)
feat: add support for Ubuntu Chisel package scanning Adds detection and parsing of Chisel manifest files to scan Ubuntu container images created with Canonical's Chisel tool. Chisel creates minimal images by installing specific "slices" of Debian packages rather than full packages. The manifest.wall file is a zstd-compressed NDJSON file that records all installed packages and slices. Changes: - Parse /var/lib/chisel/manifest.wall to extract package information - Add zstd buffer decompression utility for manifest processing - Treat Chisel packages as Debian packages for vulnerability scanning - Detect Chisel images that lack standard OS release files The analyzer extracts only package entries from the manifest (ignoring slice, path, and content entries) and converts them to the standard AnalyzedPackage format. Dependencies aren't included since they're pre-resolved by Chisel and not exposed in the manifest.
1 parent 1a4f693 commit e9a6a45

File tree

11 files changed

+982
-2
lines changed

11 files changed

+982
-2
lines changed

lib/analyzer/os-release/static.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Debug from "debug";
2+
import { normalize as normalizePath } from "path";
23

34
import { DockerFileAnalysis } from "../../dockerfile/types";
45
import { ExtractedLayers } from "../../extractor/types";
@@ -17,8 +18,22 @@ import {
1718

1819
const debug = Debug("snyk");
1920

21+
const CHISEL_MANIFEST_PATH = "/var/lib/chisel/manifest.wall";
22+
2023
type OsReleaseHandler = (text: string) => Promise<OSRelease | null>;
2124

25+
/**
26+
* Checks if a Chisel manifest exists in the extracted layers.
27+
* Chisel is Ubuntu's tool for creating minimal container images.
28+
*
29+
* @param extractedLayers - Layers extracted from the Docker image
30+
* @returns true if Chisel manifest.wall file is present
31+
*/
32+
function hasChiselManifest(extractedLayers: ExtractedLayers): boolean {
33+
const manifestPath = normalizePath(CHISEL_MANIFEST_PATH);
34+
return manifestPath in extractedLayers;
35+
}
36+
2237
const releaseDetectors: Record<OsReleaseFilePath, OsReleaseHandler> = {
2338
[OsReleaseFilePath.Linux]: tryOSRelease,
2439
// Fallback for the case where the same file exists in different location or is a symlink to the other location
@@ -71,7 +86,24 @@ export async function detect(
7186
}
7287

7388
if (!osRelease) {
74-
if (dockerfileAnalysis && dockerfileAnalysis.baseImage === "scratch") {
89+
// Check if this is a Chisel image without OS release information
90+
const isChiselImage = hasChiselManifest(extractedLayers);
91+
92+
if (isChiselImage) {
93+
// Chisel images detected but OS version could not be determined
94+
// This happens when ultra-minimal Chisel slices are used without base-files_release-info
95+
debug(
96+
`Chisel manifest found at ${CHISEL_MANIFEST_PATH} but no OS release files detected`,
97+
);
98+
99+
// Set OS name to "chisel" so downstream systems can identify these images
100+
// note we only do this to alert the user that they are missing release info
101+
// when they have release info, we identify the image with that instead
102+
osRelease = { name: "chisel", version: "0.0", prettyName: "" };
103+
} else if (
104+
dockerfileAnalysis &&
105+
dockerfileAnalysis.baseImage === "scratch"
106+
) {
75107
// If the docker file was build from a scratch image
76108
// then we don't have a known OS
77109
osRelease = { name: "scratch", version: "0.0", prettyName: "" };
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
AnalysisType,
3+
AnalyzedPackageWithVersion,
4+
ChiselPackage,
5+
ImagePackagesAnalysis,
6+
} from "../types";
7+
8+
/**
9+
* Analyzes Ubuntu Chisel packages from a Docker image.
10+
*
11+
* Chisel is Canonical's tool for creating ultra-minimal Ubuntu container images
12+
* by installing only specific "slices" of Debian packages rather than full packages.
13+
* Packages are converted to the standard AnalyzedPackage format and scanned for
14+
* vulnerabilities as Debian packages.
15+
*
16+
* @param targetImage - The Docker image identifier being analyzed
17+
* @param packages - Array of Chisel packages extracted from the manifest
18+
* @returns Promise resolving to image package analysis results
19+
*
20+
* @see https://documentation.ubuntu.com/chisel/en/latest/
21+
*/
22+
export function analyze(
23+
targetImage: string,
24+
packages: ChiselPackage[],
25+
): Promise<ImagePackagesAnalysis> {
26+
// Convert Chisel packages to standard analyzed package format
27+
// Note: Chisel packages are treated as Debian packages for vulnerability scanning
28+
// since they originate from Ubuntu/Debian package archives
29+
const analysis: AnalyzedPackageWithVersion[] = packages.map((pkg) => ({
30+
Name: pkg.name,
31+
Version: pkg.version,
32+
Source: undefined, // Source package info not available in Chisel manifest
33+
Provides: [], // Virtual package provides not tracked in Chisel
34+
Deps: {}, // Dependencies are pre-resolved by Chisel; not exposed in manifest
35+
AutoInstalled: undefined, // Not applicable - all Chisel packages are explicitly installed
36+
}));
37+
38+
return Promise.resolve({
39+
Image: targetImage,
40+
AnalyzeType: AnalysisType.Chisel,
41+
Analysis: analysis,
42+
});
43+
}

lib/analyzer/static-analyzer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
getNodeBinariesFileContentAction,
2121
getOpenJDKBinariesFileContentAction,
2222
} from "../inputs/binaries/static";
23+
import {
24+
getChiselManifestAction,
25+
getChiselManifestContent,
26+
} from "../inputs/chisel/static";
2327
import {
2428
getAptFiles,
2529
getDpkgPackageFileContentAction,
@@ -69,6 +73,7 @@ import {
6973
analyze as aptAnalyze,
7074
analyzeDistroless as aptDistrolessAnalyze,
7175
} from "./package-managers/apt";
76+
import { analyze as chiselAnalyze } from "./package-managers/chisel";
7277
import {
7378
analyze as rpmAnalyze,
7479
mapRpmSqlitePackages,
@@ -96,6 +101,7 @@ export async function analyze(
96101
getRpmDbFileContentAction,
97102
getRpmSqliteDbFileContentAction,
98103
getRpmNdbFileContentAction,
104+
getChiselManifestAction,
99105
...getOsReleaseActions,
100106
getNodeBinariesFileContentAction,
101107
getOpenJDKBinariesFileContentAction,
@@ -167,12 +173,14 @@ export async function analyze(
167173
rpmDbFileContent,
168174
rpmSqliteDbFileContent,
169175
rpmNdbFileContent,
176+
chiselPackages,
170177
] = await Promise.all([
171178
getApkDbFileContent(extractedLayers),
172179
getAptDbFileContent(extractedLayers),
173180
getRpmDbFileContent(extractedLayers),
174181
getRpmSqliteDbFileContent(extractedLayers),
175182
getRpmNdbFileContent(extractedLayers),
183+
getChiselManifestContent(extractedLayers),
176184
]);
177185

178186
const distrolessAptFiles = getAptFiles(extractedLayers);
@@ -215,6 +223,7 @@ export async function analyze(
215223
osRelease,
216224
),
217225
aptDistrolessAnalyze(targetImage, distrolessAptFiles, osRelease),
226+
chiselAnalyze(targetImage, chiselPackages),
218227
]);
219228
} catch (err) {
220229
debug(`Could not detect installed OS packages: ${err.message}`);

lib/analyzer/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export enum AnalysisType {
4040
Apk = "Apk",
4141
Apt = "Apt",
4242
Rpm = "Rpm",
43+
Chisel = "Chisel",
4344
Binaries = "binaries",
4445
Linux = "linux", // default/unknown/tech-debt
4546
}
@@ -109,3 +110,11 @@ export interface SourcePackage {
109110
version: string;
110111
release: string;
111112
}
113+
114+
export interface ChiselPackage {
115+
kind: "package";
116+
name: string;
117+
version: string;
118+
sha256: string;
119+
arch: string;
120+
}

lib/compression-utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Decompress as ZstdDecompress } from "fzstd";
2+
3+
/**
4+
* Decompresses zstd-compressed data from a buffer.
5+
*
6+
* This is a synchronous buffer-to-buffer decompression utility.
7+
* For streaming zstd decompression, use the decompressMaybe transform stream.
8+
*
9+
* @param compressed Buffer containing zstd-compressed data
10+
* @returns Decompressed data as a Buffer
11+
* @throws Error if decompression fails
12+
*/
13+
export function decompressZstd(compressed: Buffer): Buffer {
14+
const chunks: Buffer[] = [];
15+
16+
try {
17+
const decompressor = new ZstdDecompress((data: Uint8Array) => {
18+
chunks.push(Buffer.from(data));
19+
});
20+
21+
decompressor.push(new Uint8Array(compressed), true);
22+
23+
return Buffer.concat(chunks);
24+
} catch (error) {
25+
throw new Error(
26+
`Zstd decompression failed: ${
27+
error instanceof Error ? error.message : String(error)
28+
}`,
29+
);
30+
}
31+
}

lib/inputs/chisel/static.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as Debug from "debug";
2+
import { normalize as normalizePath } from "path";
3+
import { ChiselPackage } from "../../analyzer/types";
4+
import { decompressZstd } from "../../compression-utils";
5+
import { getContentAsBuffer } from "../../extractor";
6+
import { ExtractAction, ExtractedLayers } from "../../extractor/types";
7+
import { streamToBuffer } from "../../stream-utils";
8+
9+
const debug = Debug("snyk");
10+
11+
/**
12+
* Path to the Chisel manifest file within container images.
13+
* This file contains package and slice information for Chisel-built images.
14+
*/
15+
const CHISEL_MANIFEST_PATH = "/var/lib/chisel/manifest.wall";
16+
17+
/**
18+
* Extract action for Ubuntu Chisel manifest files.
19+
*
20+
* Chisel is Ubuntu's tool for creating minimal container images by installing
21+
* only specific "slices" of Debian packages. The manifest.wall file is a
22+
* zstd-compressed NDJSON (newline-delimited JSON) file that records all
23+
* installed packages, slices, and files for integrity verification and SBOM generation.
24+
*
25+
* See: https://documentation.ubuntu.com/chisel/en/latest/reference/manifest/
26+
*/
27+
export const getChiselManifestAction: ExtractAction = {
28+
actionName: "chisel-manifest",
29+
filePathMatches: (filePath) =>
30+
filePath === normalizePath(CHISEL_MANIFEST_PATH),
31+
callback: streamToBuffer,
32+
};
33+
34+
/**
35+
* Extracts and parses Chisel package information from Docker image layers.
36+
*
37+
* Searches for the Chisel manifest file (/var/lib/chisel/manifest.wall), decompresses it,
38+
* and extracts package entries. The manifest uses NDJSON format where each line is a
39+
* separate JSON object with a "kind" field indicating the entry type.
40+
*
41+
* @param extractedLayers - Layers extracted from the Docker image
42+
* @returns Array of Chisel packages found in the manifest, or empty array if not found
43+
*/
44+
export function getChiselManifestContent(
45+
extractedLayers: ExtractedLayers,
46+
): ChiselPackage[] {
47+
const compressedManifest = getContentAsBuffer(
48+
extractedLayers,
49+
getChiselManifestAction,
50+
);
51+
52+
if (!compressedManifest) {
53+
return [];
54+
}
55+
56+
try {
57+
const decompressed = decompressZstd(compressedManifest);
58+
const manifestText = decompressed.toString("utf8");
59+
60+
const packages: ChiselPackage[] = [];
61+
const lines = manifestText.split("\n");
62+
63+
for (const line of lines) {
64+
if (!line.trim()) {
65+
continue;
66+
}
67+
68+
try {
69+
const entry = JSON.parse(line);
70+
// Only extract package entries; manifest also contains "slice", "path", and "content" entries
71+
if (entry.kind === "package") {
72+
// Validate required fields exist before creating package object
73+
if (!entry.name || !entry.version || !entry.sha256 || !entry.arch) {
74+
debug(
75+
`Skipping package entry with missing required fields: ${JSON.stringify(
76+
entry,
77+
)}`,
78+
);
79+
continue;
80+
}
81+
packages.push({
82+
kind: entry.kind,
83+
name: entry.name,
84+
version: entry.version,
85+
sha256: entry.sha256,
86+
arch: entry.arch,
87+
});
88+
}
89+
} catch (parseError) {
90+
// Skip malformed JSON lines - manifest may be corrupted or have trailing newlines
91+
debug(
92+
`Failed to parse Chisel manifest line: ${
93+
parseError instanceof Error
94+
? parseError.message
95+
: String(parseError)
96+
}`,
97+
);
98+
continue;
99+
}
100+
}
101+
102+
debug(`Found ${packages.length} Chisel packages in manifest`);
103+
return packages;
104+
} catch (error) {
105+
debug(
106+
`Failed to process Chisel manifest: ${
107+
error instanceof Error ? error.message : String(error)
108+
}`,
109+
);
110+
return [];
111+
}
112+
}

lib/parser/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export function parseAnalysisResults(
3434

3535
let packageFormat: string;
3636
switch (analysisResult.AnalyzeType) {
37-
case AnalysisType.Apt: {
37+
case AnalysisType.Apt:
38+
case AnalysisType.Chisel: {
3839
packageFormat = "deb";
3940
break;
4041
}

0 commit comments

Comments
 (0)