snyk-docker-plugin
Version:
Snyk CLI docker plugin
366 lines (345 loc) • 13.4 kB
text/typescript
import { depFullName } from "../dependency-tree";
import { getErrorMessage } from "../error-utils";
import { ExtractedLayers, HistoryEntry } from "../extractor/types";
import {
getApkDbFileContent,
getApkDbFileContentAction,
} from "../inputs/apk/static";
import {
getAptDbFileContent,
getDpkgFileContentAction,
} from "../inputs/apt/static";
import {
getChiselManifestAction,
getChiselManifestContent,
} from "../inputs/chisel/static";
import {
getRpmDbFileContent,
getRpmDbFileContentAction,
getRpmNdbFileContent,
getRpmNdbFileContentAction,
getRpmSqliteDbFileContent,
getRpmSqliteDbFileContentAction,
} from "../inputs/rpm/static";
import { selectPrimaryPackageAnalysis } from "../parser";
import { analyze as apkAnalyze } from "./package-managers/apk";
import { analyze as aptAnalyze } from "./package-managers/apt";
import { analyze as chiselAnalyze } from "./package-managers/chisel";
import {
analyze as rpmAnalyze,
mapRpmSqlitePackages,
} from "./package-managers/rpm";
import {
AnalysisType,
ImagePackagesAnalysis,
IntroducingLayerByPackage,
OSRelease,
} from "./types";
/**
* Checks whether the OCI "non-empty history entries map 1:1 to
* `rootfs.diff_ids[]`" rule holds for this image. Returns a warning
* string when it does not, otherwise `undefined`.
*
* The plugin's own per-package attribution path is keyed by diffID and
* does not depend on `history` alignment — those labels are correct
* either way. The backend performs the diffID -> `createdBy` join at
* read time using the separately-emitted `rootFs` and `history` facts,
* and it is the backend's responsibility to detect misalignment and
* decide whether to surface instruction text. The plugin only emits a
* warning so a human running a scan can see "instructions may not be
* shown" without needing to dig into backend logs.
*
* Alignment failure is silent at the OCI level — there is no shared key
* between `history` and `diff_ids[]`. Length equality is the only signal
* available, and it is notoriously fragile across squash builds,
* `docker save` round-trips, and some non-Docker builders (Jib, ko,
* apko, Bazel `rules_docker`).
*
* @param history `null`/`undefined` is treated as "no history to align
* against," which is not an error — there is simply nothing
* to join. Only a length mismatch between non-empty history
* entries and rootfs layers produces a warning.
*/
export function checkHistoryAlignment(
rootFsLayers: string[],
history: HistoryEntry[] | null | undefined,
): string | undefined {
if (history === null || history === undefined) {
return undefined;
}
const nonEmptyHistoryCount = history.filter((h) => !h.empty_layer).length;
if (nonEmptyHistoryCount === rootFsLayers.length) {
return undefined;
}
return (
`Layer attribution: image history does not align 1:1 with rootfs layers ` +
`(history has ${nonEmptyHistoryCount} non-empty entries, rootfs has ${rootFsLayers.length} layers). ` +
`Per-package layer attribution will still be reported, but the originating Dockerfile instruction may not be shown.`
);
}
/**
* Returns true if the layer contains a file that was processed by the given
* extract action. Used to distinguish "layer has no package DB" (return null
* → skip) from "layer has an empty package DB" (return empty Set → track).
*/
function layerHasAction(layer: ExtractedLayers, actionName: string): boolean {
return Object.values(layer).some((fileContent) => actionName in fileContent);
}
/**
* Builds a `<fullName>@<version>` Set from one or more analyzer outputs,
* where `fullName` is the same string the dep-graph builder uses for the
* package node (`<source>/<binary>` when a source/origin is known,
* otherwise just `<binary>`). Sharing `depFullName` with the dep-graph
* side is what lets `response-builder` annotate dep-graph nodes with the
* `dockerLayerDiffId` label by direct key lookup, with no string surgery.
*
* Variadic to accommodate RPM, which produces separate analyses for the
* BDB/NDB and SQLite formats.
*/
function pkgKeySetFromAnalyses(
...analyses: ImagePackagesAnalysis[]
): Set<string> {
const result = new Set<string>();
for (const analysis of analyses) {
for (const pkg of analysis.Analysis) {
result.add(`${depFullName(pkg)}@${pkg.Version}`);
}
}
return result;
}
/**
* Parses the package DB for a single layer and returns the set of
* "name@version" keys present in that layer.
*
* Returns null when the layer does not contain the package DB file at all
* (e.g. a COPY or ENV instruction). An empty Set means the DB file exists
* but is empty (e.g. all packages were removed in this layer).
*/
async function parseLayerOsPackages(
layer: ExtractedLayers,
analysisType: AnalysisType,
targetImage: string,
osRelease: OSRelease | undefined,
redHatRepositories: string[],
): Promise<Set<string> | null> {
switch (analysisType) {
case AnalysisType.Apk: {
if (!layerHasAction(layer, getApkDbFileContentAction.actionName)) {
return null;
}
return pkgKeySetFromAnalyses(
await apkAnalyze(targetImage, getApkDbFileContent(layer)),
);
}
case AnalysisType.Apt: {
if (!layerHasAction(layer, getDpkgFileContentAction.actionName)) {
return null;
}
return pkgKeySetFromAnalyses(
await aptAnalyze(targetImage, getAptDbFileContent(layer), osRelease),
);
}
case AnalysisType.Rpm: {
const hasBdb = layerHasAction(
layer,
getRpmDbFileContentAction.actionName,
);
const hasNdb = layerHasAction(
layer,
getRpmNdbFileContentAction.actionName,
);
const hasSqlite = layerHasAction(
layer,
getRpmSqliteDbFileContentAction.actionName,
);
if (!hasBdb && !hasNdb && !hasSqlite) {
return null;
}
const [bdbPkgs, ndbPkgs, sqlitePkgs] = await Promise.all([
hasBdb ? getRpmDbFileContent(layer) : Promise.resolve([]),
hasNdb ? getRpmNdbFileContent(layer) : Promise.resolve([]),
hasSqlite ? getRpmSqliteDbFileContent(layer) : Promise.resolve([]),
]);
const analyses: ImagePackagesAnalysis[] = [];
if (hasBdb || hasNdb) {
analyses.push(
await rpmAnalyze(
targetImage,
[...bdbPkgs, ...ndbPkgs],
redHatRepositories,
osRelease,
),
);
}
if (hasSqlite) {
analyses.push(
mapRpmSqlitePackages(
targetImage,
sqlitePkgs,
redHatRepositories,
osRelease,
),
);
}
return pkgKeySetFromAnalyses(...analyses);
}
case AnalysisType.Chisel: {
if (!layerHasAction(layer, getChiselManifestAction.actionName)) {
return null;
}
return pkgKeySetFromAnalyses(
await chiselAnalyze(targetImage, getChiselManifestContent(layer)),
);
}
default:
// Binaries/Linux (and any future non-OS-PM type) have no per-layer
// package DB to diff, so there is nothing to attribute.
return null;
}
}
/**
* Computes per-package layer attribution for a single OS package manager
* (Apk, Apt, Rpm, or Chisel). Returns the `<fullName>@<version>` -> diffID
* map for every package present in the *final* layer's DB.
*
* Earlier introductions whose copies were later removed do not appear in
* the result. The OS package manager dedupes, so for OS ecosystems each
* surviving key has exactly one introducing layer (the most recent layer
* to install or reinstall the surviving copy).
*
* The image-wide orchestrator is `computeOsLayerAttribution`; call this
* directly only when you already know the target ecosystem.
*/
export async function computeOsPackageManagerLayerAttribution(
orderedLayers: ExtractedLayers[],
analysisType: AnalysisType,
diffIDs: string[],
targetImage: string,
osRelease: OSRelease | undefined,
redHatRepositories: string[],
): Promise<IntroducingLayerByPackage> {
if (orderedLayers.length !== diffIDs.length) {
// These two arrays are both produced by the extractor and describe the
// same set of rootfs layers from different angles (file contents vs
// diffID). A mismatch is an internal invariant violation, not a
// malformed-image case — fail loudly so the bug surfaces.
throw new Error(
`layer attribution: orderedLayers (${orderedLayers.length}) and diffIDs (${diffIDs.length}) must align`,
);
}
// Per-key reverse index of the most recent layer to introduce each key.
// Built during the loop; the live filter at the end intersects this with
// the final layer's package set to produce the returned map.
const latestIntroductionByKey: IntroducingLayerByPackage = new Map();
let previousPkgs = new Set<string>();
for (let i = 0; i < diffIDs.length; i++) {
const currentPkgs = await parseLayerOsPackages(
orderedLayers[i],
analysisType,
targetImage,
osRelease,
redHatRepositories,
);
if (currentPkgs === null) {
// Layer has no package DB file (e.g. COPY/ENV/LABEL instruction).
// Do not update previousPkgs — the package state has not changed.
continue;
}
for (const key of currentPkgs) {
if (!previousPkgs.has(key)) {
// We only record a layer when the key was absent from the
// immediately-preceding layer's snapshot. That covers both
// first installs and reinstalls after removal (the intermediate
// empty-DB layer wipes `previousPkgs`, so the new install is
// treated as fresh). A same-version reinstall *without* an
// intermediate removal is rare in practice and would be
// attributed to the earlier install — but the survivor-set
// intersection below still produces a correct diffID for the
// copy that ends up on disk.
latestIntroductionByKey.set(key, diffIDs[i]);
}
}
previousPkgs = currentPkgs;
}
// Keep only packages that survive in the final layer's DB.
const finalImagePackages: IntroducingLayerByPackage = new Map();
for (const key of previousPkgs) {
const diffID = latestIntroductionByKey.get(key);
if (diffID) {
finalImagePackages.set(key, diffID);
}
}
return finalImagePackages;
}
/**
* Result of image-wide OS-package layer attribution.
*
* `warnings` are human-readable, non-fatal messages (currently only a
* failure to attribute the image's OS package manager). They share the
* string shape of `checkHistoryAlignment`'s return value so a caller can
* collect both into one list (e.g. the `pluginWarnings` fact).
* `introducingLayerByPackage` is always usable; warnings only flag that
* coverage may be incomplete.
*/
export interface OsLayerAttribution {
introducingLayerByPackage: IntroducingLayerByPackage;
warnings: string[];
}
/**
* Image-wide OS-package layer attribution. Produces the
* `<fullName>@<version>` -> diffID map for the image's OS packages,
* returning any non-fatal warnings alongside it (see `OsLayerAttribution`).
*
* Attribution must annotate the *same* package set the OS dep graph
* contains, and that dep graph is built from a single `ImagePackagesAnalysis`
* — `parseAnalysisResults` selects it via `selectPrimaryPackageAnalysis`
* ("first non-empty result wins") and discards the rest. So we select the
* primary analysis the exact same way here, attributing only that ecosystem.
* Re-deriving our own ecosystem list (e.g. attributing every non-empty
* analysis) would risk producing keys for an ecosystem the dep graph dropped,
* or — if the selection rules ever diverged — silently mis-attributing;
* routing both through `selectPrimaryPackageAnalysis` makes that drift
* impossible by construction. Real images carry a single OS package manager,
* so this also matches reality, not just the dep graph's bookkeeping.
*
* A scratch / unknown-PM image (no non-empty analysis) yields an empty map
* and no warnings. A failure attributing the selected ecosystem (a per-PM
* `await` that throws) is recorded as a warning rather than thrown, so the
* rest of the scan still ships.
*/
export async function computeOsLayerAttribution(
analyses: ImagePackagesAnalysis[],
orderedLayers: ExtractedLayers[],
diffIDs: string[],
targetImage: string,
osRelease: OSRelease | undefined,
redHatRepositories: string[],
): Promise<OsLayerAttribution> {
const introducingLayerByPackage: IntroducingLayerByPackage = new Map();
const warnings: string[] = [];
const primaryAnalysis = selectPrimaryPackageAnalysis(analyses);
if (primaryAnalysis === undefined) {
return { introducingLayerByPackage, warnings };
}
const analysisType = primaryAnalysis.AnalyzeType;
try {
const survivors = await computeOsPackageManagerLayerAttribution(
orderedLayers,
analysisType,
diffIDs,
targetImage,
osRelease,
redHatRepositories,
);
for (const [key, diffID] of survivors) {
introducingLayerByPackage.set(key, diffID);
}
} catch (err) {
warnings.push(
`Layer attribution: failed to attribute ${analysisType} packages: ${getErrorMessage(
err,
)}.`,
);
}
return { introducingLayerByPackage, warnings };
}