Skip to content

Commit e899abc

Browse files
ashokn1claude
andcommitted
feat: add per-layer package attribution (opt-in)
Introduces `computeLayerAttribution` in `lib/analyzer/layer-attribution.ts` and wires it through the full pipeline. Enabled with `--layer-attribution`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7936ba5 commit e899abc

28 files changed

+858
-83
lines changed

lib/analyzer/layer-attribution.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { LayerAttributionEntry } from "../facts";
2+
import { getApkDbFileContent } from "../inputs/apk/static";
3+
import { getAptDbFileContent } from "../inputs/apt/static";
4+
import { getChiselManifestContent } from "../inputs/chisel/static";
5+
import { getRpmDbFileContent } from "../inputs/rpm/static";
6+
import { ExtractedLayers, HistoryEntry } from "../extractor/types";
7+
import { analyze as apkAnalyze } from "./package-managers/apk";
8+
import { analyze as aptAnalyze } from "./package-managers/apt";
9+
import { analyze as chiselAnalyze } from "./package-managers/chisel";
10+
import { analyze as rpmAnalyze } from "./package-managers/rpm";
11+
import { AnalysisType } from "./types";
12+
13+
export interface LayerAttributionResult {
14+
entries: LayerAttributionEntry[];
15+
pkgLayerMap: Map<string, { layerIndex: number; diffID: string }>;
16+
}
17+
18+
function buildHistoryInstructions(
19+
history: HistoryEntry[] | null | undefined,
20+
): string[] {
21+
if (!history) return [];
22+
return history
23+
.filter((h) => !h.empty_layer)
24+
.map((h) => h.created_by ?? "");
25+
}
26+
27+
function pkgKey(name: string, version: string): string {
28+
return `${name}@${version}`;
29+
}
30+
31+
async function parseLayerPackages(
32+
layer: ExtractedLayers,
33+
analysisType: AnalysisType,
34+
targetImage: string,
35+
): Promise<Map<string, true>> {
36+
const result = new Map<string, true>();
37+
38+
if (analysisType === AnalysisType.Apk) {
39+
const content = getApkDbFileContent(layer);
40+
if (!content) return result;
41+
const analysis = await apkAnalyze(targetImage, content);
42+
for (const pkg of analysis.Analysis) {
43+
result.set(pkgKey(pkg.Name, pkg.Version), true);
44+
}
45+
} else if (analysisType === AnalysisType.Apt) {
46+
const aptFiles = getAptDbFileContent(layer);
47+
if (!aptFiles.dpkgFile) return result;
48+
const analysis = await aptAnalyze(targetImage, aptFiles);
49+
for (const pkg of analysis.Analysis) {
50+
result.set(pkgKey(pkg.Name, pkg.Version), true);
51+
}
52+
} else if (analysisType === AnalysisType.Rpm) {
53+
const pkgs = await getRpmDbFileContent(layer);
54+
if (!pkgs.length) return result;
55+
const analysis = await rpmAnalyze(targetImage, pkgs, []);
56+
for (const pkg of analysis.Analysis) {
57+
result.set(pkgKey(pkg.Name, pkg.Version), true);
58+
}
59+
} else if (analysisType === AnalysisType.Chisel) {
60+
const pkgs = getChiselManifestContent(layer);
61+
if (!pkgs.length) return result;
62+
const analysis = await chiselAnalyze(targetImage, pkgs);
63+
for (const pkg of analysis.Analysis) {
64+
result.set(pkgKey(pkg.Name, pkg.Version), true);
65+
}
66+
}
67+
68+
return result;
69+
}
70+
71+
export async function computeLayerAttribution(
72+
orderedLayers: ExtractedLayers[],
73+
analysisType: AnalysisType,
74+
rootFsLayers: string[],
75+
manifestLayers: string[],
76+
history: HistoryEntry[] | null | undefined,
77+
targetImage: string,
78+
): Promise<LayerAttributionResult> {
79+
const instructions = buildHistoryInstructions(history);
80+
const entries: LayerAttributionEntry[] = [];
81+
const pkgLayerMap = new Map<string, { layerIndex: number; diffID: string }>();
82+
const limit = Math.min(orderedLayers.length, rootFsLayers.length);
83+
84+
let previousPkgs = new Map<string, true>();
85+
86+
for (let i = 0; i < limit; i++) {
87+
const diffID = rootFsLayers[i];
88+
const digest = manifestLayers[i];
89+
const instruction = instructions[i];
90+
91+
const currentPkgs = await parseLayerPackages(
92+
orderedLayers[i],
93+
analysisType,
94+
targetImage,
95+
);
96+
if (currentPkgs.size === 0) continue;
97+
98+
const newPkgs: string[] = [];
99+
for (const key of currentPkgs.keys()) {
100+
if (!previousPkgs.has(key)) {
101+
newPkgs.push(key);
102+
pkgLayerMap.set(key, { layerIndex: i, diffID });
103+
}
104+
}
105+
106+
const removedPkgs: string[] = [];
107+
for (const key of previousPkgs.keys()) {
108+
if (!currentPkgs.has(key)) {
109+
removedPkgs.push(key);
110+
}
111+
}
112+
113+
if (newPkgs.length > 0 || removedPkgs.length > 0) {
114+
const entry: LayerAttributionEntry = {
115+
layerIndex: i,
116+
diffID,
117+
packages: newPkgs,
118+
};
119+
if (digest) entry.digest = digest;
120+
if (instruction) entry.instruction = instruction;
121+
if (removedPkgs.length > 0) entry.removedPackages = removedPkgs;
122+
entries.push(entry);
123+
}
124+
125+
previousPkgs = currentPkgs;
126+
}
127+
128+
return { entries, pkgLayerMap };
129+
}

lib/analyzer/static-analyzer.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,13 @@ import {
8181
analyze as rpmAnalyze,
8282
mapRpmSqlitePackages,
8383
} from "./package-managers/rpm";
84+
import { computeLayerAttribution } from "./layer-attribution";
8485
import {
8586
ImagePackagesAnalysis,
8687
OSRelease,
8788
StaticPackagesAnalysis,
8889
} from "./types";
90+
import { LayerAttributionEntry } from "../facts";
8991

9092
const debug = Debug("snyk");
9193

@@ -159,6 +161,7 @@ export async function analyze(
159161
imageId,
160162
manifestLayers,
161163
extractedLayers,
164+
orderedLayers,
162165
rootFsLayers,
163166
autoDetectedUserInstructions,
164167
platform,
@@ -236,6 +239,34 @@ export async function analyze(
236239
throw new Error("Failed to detect installed OS packages");
237240
}
238241

242+
let layerPackageAttribution: LayerAttributionEntry[] | undefined;
243+
if (isTrue(options["layer-attribution"]) && rootFsLayers && orderedLayers.length > 0) {
244+
const winningResult = results.find((r) => r.Analysis.length > 0);
245+
if (winningResult) {
246+
try {
247+
const { entries, pkgLayerMap } = await computeLayerAttribution(
248+
orderedLayers,
249+
winningResult.AnalyzeType,
250+
rootFsLayers,
251+
manifestLayers,
252+
history,
253+
targetImage,
254+
);
255+
layerPackageAttribution = entries;
256+
for (const pkg of winningResult.Analysis) {
257+
const key = `${pkg.Name}@${pkg.Version}`;
258+
const attr = pkgLayerMap.get(key);
259+
if (attr) {
260+
pkg.layerIndex = attr.layerIndex;
261+
pkg.layerDiffId = attr.diffID;
262+
}
263+
}
264+
} catch (err) {
265+
debug(`Could not compute layer attribution: ${getErrorMessage(err)}`);
266+
}
267+
}
268+
}
269+
239270
const binaries = getBinariesHashes(extractedLayers);
240271
const javaRuntime = detectJavaRuntime(extractedLayers);
241272
const baseRuntimes = javaRuntime ? [javaRuntime] : undefined;
@@ -318,6 +349,7 @@ export async function analyze(
318349
baseRuntimes,
319350
imageLayers: manifestLayers,
320351
rootFsLayers,
352+
layerPackageAttribution,
321353
applicationDependenciesScanResults,
322354
manifestFiles,
323355
autoDetectedUserInstructions,

lib/analyzer/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ImageName } from "../extractor/image";
2-
import { BaseRuntime } from "../facts";
2+
import { BaseRuntime, LayerAttributionEntry } from "../facts";
33
import { AutoDetectedUserInstructions, ManifestFile } from "../types";
44
import {
55
AppDepsScanResultWithoutTarget,
@@ -17,6 +17,8 @@ export interface AnalyzedPackage {
1717
};
1818
Purl?: string;
1919
AutoInstalled?: boolean;
20+
layerIndex?: number;
21+
layerDiffId?: string;
2022
}
2123
export interface AnalyzedPackageWithVersion extends AnalyzedPackage {
2224
Version: string;
@@ -79,6 +81,7 @@ export interface StaticAnalysis {
7981
baseRuntimes?: BaseRuntime[];
8082
imageLayers: string[];
8183
rootFsLayers?: string[];
84+
layerPackageAttribution?: LayerAttributionEntry[];
8285
autoDetectedUserInstructions?: AutoDetectedUserInstructions;
8386
applicationDependenciesScanResults: AppDepsScanResultWithoutTarget[];
8487
manifestFiles: ManifestFile[];

lib/dependency-tree/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,19 @@ export function buildTree(
121121
};
122122

123123
for (const depInfo of tooFrequentDeps) {
124+
const freqLabels: { [key: string]: string } = {};
125+
if (depInfo.layerDiffId !== undefined) {
126+
freqLabels.layerDiffId = depInfo.layerDiffId;
127+
}
128+
if (depInfo.layerIndex !== undefined) {
129+
freqLabels.layerIndex = String(depInfo.layerIndex);
130+
}
124131
const pkg: DepTreeDep = {
125132
name: depFullName(depInfo),
126133
version: depInfo.Version,
127134
sourceVersion: depInfo.SourceVersion,
128135
dependencies: {},
136+
...(Object.keys(freqLabels).length > 0 ? { labels: freqLabels } : {}),
129137
};
130138

131139
// The existence of the "meta" package breaks upgrade
@@ -172,11 +180,20 @@ function buildTreeRecursive(
172180
return null;
173181
}
174182

183+
const labels: { [key: string]: string } = {};
184+
if (depInfo.layerDiffId !== undefined) {
185+
labels.layerDiffId = depInfo.layerDiffId;
186+
}
187+
if (depInfo.layerIndex !== undefined) {
188+
labels.layerIndex = String(depInfo.layerIndex);
189+
}
190+
175191
const tree: DepTreeDep = {
176192
name: fullName,
177193
version: depInfo.Version,
178194
purl: depInfo.Purl,
179195
dependencies: {},
196+
...(Object.keys(labels).length > 0 ? { labels } : {}),
180197
};
181198
if (depInfo._visited) {
182199
return tree;

lib/extractor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export async function extractImageContent(
147147
manifestLayers: extractor.getManifestLayers(archiveContent.manifest),
148148
imageCreationTime: archiveContent.imageConfig.created,
149149
extractedLayers: layersWithLatestFileModifications(archiveContent.layers),
150+
orderedLayers: archiveContent.layers,
150151
rootFsLayers: getRootFsLayersFromConfig(archiveContent.imageConfig),
151152
autoDetectedUserInstructions: getDetectedLayersInfoFromConfig(
152153
archiveContent.imageConfig,

lib/extractor/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface ExtractionResult {
2929
imageId: string;
3030
manifestLayers: string[];
3131
extractedLayers: ExtractedLayers;
32+
orderedLayers: ExtractedLayers[];
3233
rootFsLayers?: string[];
3334
autoDetectedUserInstructions?: AutoDetectedUserInstructions;
3435
platform?: string;

lib/facts.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,17 @@ export interface BaseRuntimesFact {
162162
type: "baseRuntimes";
163163
data: BaseRuntime[];
164164
}
165+
166+
export interface LayerAttributionEntry {
167+
layerIndex: number;
168+
diffID: string;
169+
digest?: string;
170+
instruction?: string;
171+
packages: string[];
172+
removedPackages?: string[];
173+
}
174+
175+
export interface LayerPackageAttributionFact {
176+
type: "layerPackageAttribution";
177+
data: LayerAttributionEntry[];
178+
}

lib/response-builder.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,17 @@ async function buildResponse(
186186
additionalFacts.push(rootFsFact);
187187
}
188188

189+
if (
190+
depsAnalysis.layerPackageAttribution &&
191+
depsAnalysis.layerPackageAttribution.length > 0
192+
) {
193+
const layerPackageAttributionFact: facts.LayerPackageAttributionFact = {
194+
type: "layerPackageAttribution",
195+
data: depsAnalysis.layerPackageAttribution,
196+
};
197+
additionalFacts.push(layerPackageAttributionFact);
198+
}
199+
189200
if (depsAnalysis.depTree.targetOS.prettyName) {
190201
const imageOsReleasePrettyNameFact: facts.ImageOsReleasePrettyNameFact = {
191202
type: "imageOsReleasePrettyName",

lib/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export type FactType =
8282
// Used for application dependencies scanning; shows which files were used in the analysis of the dependencies.
8383
| "testedFiles"
8484
// Application files observed in the image
85-
| "applicationFiles";
85+
| "applicationFiles"
86+
// Per-layer package attribution: which layer introduced each OS package
87+
| "layerPackageAttribution";
8688

8789
export interface PluginResponse {
8890
/** The first result is guaranteed to be the OS dependencies scan result. */
@@ -238,6 +240,9 @@ export interface PluginOptions {
238240
/** Include system-level JARs and WARs from /usr/lib in scan results. The default is "false". */
239241
"include-system-jars": boolean | string;
240242

243+
/** Compute and emit per-layer package attribution. The default is "false". */
244+
"layer-attribution": boolean | string;
245+
241246
"target-reference": string;
242247

243248
parameterWarnings?: string[];

0 commit comments

Comments
 (0)