Skip to content

Commit 8d6f659

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 8d6f659

28 files changed

+870
-83
lines changed

lib/analyzer/layer-attribution.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ExtractedLayers, HistoryEntry } from "../extractor/types";
2+
import { LayerAttributionEntry } from "../facts";
3+
import { getApkDbFileContent } from "../inputs/apk/static";
4+
import { getAptDbFileContent } from "../inputs/apt/static";
5+
import { getChiselManifestContent } from "../inputs/chisel/static";
6+
import { getRpmDbFileContent } from "../inputs/rpm/static";
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) {
22+
return [];
23+
}
24+
return history.filter((h) => !h.empty_layer).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) {
41+
return result;
42+
}
43+
const analysis = await apkAnalyze(targetImage, content);
44+
for (const pkg of analysis.Analysis) {
45+
result.set(pkgKey(pkg.Name, pkg.Version), true);
46+
}
47+
} else if (analysisType === AnalysisType.Apt) {
48+
const aptFiles = getAptDbFileContent(layer);
49+
if (!aptFiles.dpkgFile) {
50+
return result;
51+
}
52+
const analysis = await aptAnalyze(targetImage, aptFiles);
53+
for (const pkg of analysis.Analysis) {
54+
result.set(pkgKey(pkg.Name, pkg.Version), true);
55+
}
56+
} else if (analysisType === AnalysisType.Rpm) {
57+
const pkgs = await getRpmDbFileContent(layer);
58+
if (!pkgs.length) {
59+
return result;
60+
}
61+
const analysis = await rpmAnalyze(targetImage, pkgs, []);
62+
for (const pkg of analysis.Analysis) {
63+
result.set(pkgKey(pkg.Name, pkg.Version), true);
64+
}
65+
} else if (analysisType === AnalysisType.Chisel) {
66+
const pkgs = getChiselManifestContent(layer);
67+
if (!pkgs.length) {
68+
return result;
69+
}
70+
const analysis = await chiselAnalyze(targetImage, pkgs);
71+
for (const pkg of analysis.Analysis) {
72+
result.set(pkgKey(pkg.Name, pkg.Version), true);
73+
}
74+
}
75+
76+
return result;
77+
}
78+
79+
export async function computeLayerAttribution(
80+
orderedLayers: ExtractedLayers[],
81+
analysisType: AnalysisType,
82+
rootFsLayers: string[],
83+
manifestLayers: string[],
84+
history: HistoryEntry[] | null | undefined,
85+
targetImage: string,
86+
): Promise<LayerAttributionResult> {
87+
const instructions = buildHistoryInstructions(history);
88+
const entries: LayerAttributionEntry[] = [];
89+
const pkgLayerMap = new Map<string, { layerIndex: number; diffID: string }>();
90+
const limit = Math.min(orderedLayers.length, rootFsLayers.length);
91+
92+
let previousPkgs = new Map<string, true>();
93+
94+
for (let i = 0; i < limit; i++) {
95+
const diffID = rootFsLayers[i];
96+
const digest = manifestLayers[i];
97+
const instruction = instructions[i];
98+
99+
const currentPkgs = await parseLayerPackages(
100+
orderedLayers[i],
101+
analysisType,
102+
targetImage,
103+
);
104+
if (currentPkgs.size === 0) {
105+
continue;
106+
}
107+
108+
const newPkgs: string[] = [];
109+
for (const key of currentPkgs.keys()) {
110+
if (!previousPkgs.has(key)) {
111+
newPkgs.push(key);
112+
pkgLayerMap.set(key, { layerIndex: i, diffID });
113+
}
114+
}
115+
116+
const removedPkgs: string[] = [];
117+
for (const key of previousPkgs.keys()) {
118+
if (!currentPkgs.has(key)) {
119+
removedPkgs.push(key);
120+
}
121+
}
122+
123+
if (newPkgs.length > 0 || removedPkgs.length > 0) {
124+
const entry: LayerAttributionEntry = {
125+
layerIndex: i,
126+
diffID,
127+
packages: newPkgs,
128+
};
129+
if (digest) {
130+
entry.digest = digest;
131+
}
132+
if (instruction) {
133+
entry.instruction = instruction;
134+
}
135+
if (removedPkgs.length > 0) {
136+
entry.removedPackages = removedPkgs;
137+
}
138+
entries.push(entry);
139+
}
140+
141+
previousPkgs = currentPkgs;
142+
}
143+
144+
return { entries, pkgLayerMap };
145+
}

lib/analyzer/static-analyzer.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Debug from "debug";
22
import { DockerFileAnalysis } from "../dockerfile";
33
import { getErrorMessage } from "../error-utils";
44
import * as archiveExtractor from "../extractor";
5+
import { LayerAttributionEntry } from "../facts";
56
import {
67
getGoModulesContentAction,
78
goModulesToScannedProjects,
@@ -70,6 +71,7 @@ import { pipFilesToScannedProjects } from "./applications/python";
7071
import { getApplicationFiles } from "./applications/runtime-common";
7172
import { AppDepsScanResultWithoutTarget } from "./applications/types";
7273
import { detectJavaRuntime } from "./base-runtimes";
74+
import { computeLayerAttribution } from "./layer-attribution";
7375
import * as osReleaseDetector from "./os-release";
7476
import { analyze as apkAnalyze } from "./package-managers/apk";
7577
import {
@@ -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,38 @@ export async function analyze(
236239
throw new Error("Failed to detect installed OS packages");
237240
}
238241

242+
let layerPackageAttribution: LayerAttributionEntry[] | undefined;
243+
if (
244+
isTrue(options["layer-attribution"]) &&
245+
rootFsLayers &&
246+
orderedLayers.length > 0
247+
) {
248+
const winningResult = results.find((r) => r.Analysis.length > 0);
249+
if (winningResult) {
250+
try {
251+
const { entries, pkgLayerMap } = await computeLayerAttribution(
252+
orderedLayers,
253+
winningResult.AnalyzeType,
254+
rootFsLayers,
255+
manifestLayers,
256+
history,
257+
targetImage,
258+
);
259+
layerPackageAttribution = entries;
260+
for (const pkg of winningResult.Analysis) {
261+
const key = `${pkg.Name}@${pkg.Version}`;
262+
const attr = pkgLayerMap.get(key);
263+
if (attr) {
264+
pkg.layerIndex = attr.layerIndex;
265+
pkg.layerDiffId = attr.diffID;
266+
}
267+
}
268+
} catch (err) {
269+
debug(`Could not compute layer attribution: ${getErrorMessage(err)}`);
270+
}
271+
}
272+
}
273+
239274
const binaries = getBinariesHashes(extractedLayers);
240275
const javaRuntime = detectJavaRuntime(extractedLayers);
241276
const baseRuntimes = javaRuntime ? [javaRuntime] : undefined;
@@ -318,6 +353,7 @@ export async function analyze(
318353
baseRuntimes,
319354
imageLayers: manifestLayers,
320355
rootFsLayers,
356+
layerPackageAttribution,
321357
applicationDependenciesScanResults,
322358
manifestFiles,
323359
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)