UNPKG

snyk-docker-plugin

Version:
239 lines 12.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.computeOsLayerAttribution = exports.computeOsPackageManagerLayerAttribution = exports.checkHistoryAlignment = void 0; const dependency_tree_1 = require("../dependency-tree"); const error_utils_1 = require("../error-utils"); const static_1 = require("../inputs/apk/static"); const static_2 = require("../inputs/apt/static"); const static_3 = require("../inputs/chisel/static"); const static_4 = require("../inputs/rpm/static"); const parser_1 = require("../parser"); const apk_1 = require("./package-managers/apk"); const apt_1 = require("./package-managers/apt"); const chisel_1 = require("./package-managers/chisel"); const rpm_1 = require("./package-managers/rpm"); const types_1 = require("./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. */ function checkHistoryAlignment(rootFsLayers, history) { 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.`); } exports.checkHistoryAlignment = checkHistoryAlignment; /** * 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, actionName) { 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) { const result = new Set(); for (const analysis of analyses) { for (const pkg of analysis.Analysis) { result.add(`${(0, dependency_tree_1.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, analysisType, targetImage, osRelease, redHatRepositories) { switch (analysisType) { case types_1.AnalysisType.Apk: { if (!layerHasAction(layer, static_1.getApkDbFileContentAction.actionName)) { return null; } return pkgKeySetFromAnalyses(await (0, apk_1.analyze)(targetImage, (0, static_1.getApkDbFileContent)(layer))); } case types_1.AnalysisType.Apt: { if (!layerHasAction(layer, static_2.getDpkgFileContentAction.actionName)) { return null; } return pkgKeySetFromAnalyses(await (0, apt_1.analyze)(targetImage, (0, static_2.getAptDbFileContent)(layer), osRelease)); } case types_1.AnalysisType.Rpm: { const hasBdb = layerHasAction(layer, static_4.getRpmDbFileContentAction.actionName); const hasNdb = layerHasAction(layer, static_4.getRpmNdbFileContentAction.actionName); const hasSqlite = layerHasAction(layer, static_4.getRpmSqliteDbFileContentAction.actionName); if (!hasBdb && !hasNdb && !hasSqlite) { return null; } const [bdbPkgs, ndbPkgs, sqlitePkgs] = await Promise.all([ hasBdb ? (0, static_4.getRpmDbFileContent)(layer) : Promise.resolve([]), hasNdb ? (0, static_4.getRpmNdbFileContent)(layer) : Promise.resolve([]), hasSqlite ? (0, static_4.getRpmSqliteDbFileContent)(layer) : Promise.resolve([]), ]); const analyses = []; if (hasBdb || hasNdb) { analyses.push(await (0, rpm_1.analyze)(targetImage, [...bdbPkgs, ...ndbPkgs], redHatRepositories, osRelease)); } if (hasSqlite) { analyses.push((0, rpm_1.mapRpmSqlitePackages)(targetImage, sqlitePkgs, redHatRepositories, osRelease)); } return pkgKeySetFromAnalyses(...analyses); } case types_1.AnalysisType.Chisel: { if (!layerHasAction(layer, static_3.getChiselManifestAction.actionName)) { return null; } return pkgKeySetFromAnalyses(await (0, chisel_1.analyze)(targetImage, (0, static_3.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. */ async function computeOsPackageManagerLayerAttribution(orderedLayers, analysisType, diffIDs, targetImage, osRelease, redHatRepositories) { 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 = new Map(); let previousPkgs = new Set(); 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 = new Map(); for (const key of previousPkgs) { const diffID = latestIntroductionByKey.get(key); if (diffID) { finalImagePackages.set(key, diffID); } } return finalImagePackages; } exports.computeOsPackageManagerLayerAttribution = computeOsPackageManagerLayerAttribution; /** * 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. */ async function computeOsLayerAttribution(analyses, orderedLayers, diffIDs, targetImage, osRelease, redHatRepositories) { const introducingLayerByPackage = new Map(); const warnings = []; const primaryAnalysis = (0, parser_1.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: ${(0, error_utils_1.getErrorMessage)(err)}.`); } return { introducingLayerByPackage, warnings }; } exports.computeOsLayerAttribution = computeOsLayerAttribution; //# sourceMappingURL=layer-attribution.js.map