Skip to content

Commit df1dfe6

Browse files
committed
fix(providers): analyze all FROM lines in multi-stage Dockerfiles via batch analysis
Change the Dockerfile/Containerfile provider to parse every FROM instruction instead of only the last one. Each image gets its own SBOM in a purl-keyed map that is routed to the /api/v5/batch-analysis endpoint via requestStackBatch(). - Rename parseFromImage() to parseAllFromImages() returning an array - Build sbomByPurl map in getImageSBOM() following requestImages() pattern - Add batch flag to provider result for routing in analysis.js - Resolve ARG substitutions using declared defaults (matching VS Code extension) - FROM lines with unresolvable ARGs (no default) are skipped without failing Implements TC-5070 Assisted-by: Claude Code
1 parent be56bee commit df1dfe6

4 files changed

Lines changed: 271 additions & 37 deletions

File tree

src/analysis.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) {
2424
opts["source-manifest"] = Buffer.from(fs.readFileSync(manifest).toString()).toString('base64')
2525
opts["manifest-type"] = path.parse(manifest).base
2626
let provided = await provider.provideStack(manifest, opts) // throws error if content providing failed
27+
if (provided.batch) {
28+
return requestStackBatch(JSON.parse(provided.content), url, html, opts)
29+
}
2730
opts["source-manifest"] = ""
2831
opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "stack-analysis"
2932
let startTime = new Date()
@@ -88,6 +91,9 @@ async function requestComponent(provider, manifest, url, opts = {}) {
8891
opts["source-manifest"] = Buffer.from(fs.readFileSync(manifest).toString()).toString('base64')
8992

9093
let provided = await provider.provideComponent(manifest, opts) // throws error if content providing failed
94+
if (provided.batch) {
95+
return requestStackBatch(JSON.parse(provided.content), url, false, opts)
96+
}
9197
opts["source-manifest"] = ""
9298
opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "component-analysis"
9399
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {

src/providers/containerfile_parser.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,8 @@ export async function getFromQuery() {
1919
const language = await init();
2020
return new Query(language, '(from_instruction (image_spec) @image)');
2121
}
22+
23+
export async function getArgQuery() {
24+
const language = await init();
25+
return new Query(language, '(arg_instruction (arg_pair name: (unquoted_string) @name default: (unquoted_string) @default))');
26+
}

src/providers/oci_dockerfile.js

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'node:fs'
22

33
import { generateImageSBOM, parseImageRef } from '../oci_image/utils.js'
44

5-
import { getFromQuery, getParser } from './containerfile_parser.js'
5+
import { getArgQuery, getFromQuery, getParser } from './containerfile_parser.js'
66

77
export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest, packageManagerName() { return 'oci' } }
88

@@ -51,43 +51,103 @@ function containsExpansion(node) {
5151
}
5252

5353
/**
54-
* Parse the last FROM instruction from a Dockerfile using tree-sitter to extract the base image reference.
55-
* In multi-stage builds, the last FROM represents the final stage.
54+
* Collect ARG key-value pairs from the Dockerfile AST.
55+
* Only ARGs with a default value are collected (ARGs without defaults cannot be resolved statically).
56+
* @param {import('web-tree-sitter').Tree} tree the parsed Dockerfile tree
57+
* @param {import('web-tree-sitter').Query} argQuery the tree-sitter query for arg_instruction nodes
58+
* @returns {Map<string, string>} map of ARG names to their default values
59+
* @private
60+
*/
61+
function collectArgs(tree, argQuery) {
62+
const args = new Map()
63+
for (const match of argQuery.matches(tree.rootNode)) {
64+
const name = match.captures.find(c => c.name === 'name')?.node.text
65+
const defaultValue = match.captures.find(c => c.name === 'default')?.node.text
66+
if (name && defaultValue) {
67+
args.set(name, defaultValue)
68+
}
69+
}
70+
return args
71+
}
72+
73+
/**
74+
* Resolve ARG substitutions in an image spec text using the collected ARG map.
75+
* Replaces ${VAR} and $VAR patterns with their ARG default values.
76+
* @param {string} text the image spec text potentially containing variable references
77+
* @param {Map<string, string>} args map of ARG names to their default values
78+
* @returns {string|null} the resolved text, or null if any variable could not be resolved
79+
* @private
80+
*/
81+
function resolveArgs(text, args) {
82+
let resolved = text
83+
let hasUnresolved = false
84+
resolved = resolved.replace(/\$\{([^}]+)\}|\$([A-Za-z_]\w*)/g, (_match, braced, plain) => {
85+
const key = braced || plain
86+
if (args.has(key)) {
87+
return args.get(key)
88+
}
89+
hasUnresolved = true
90+
return _match
91+
})
92+
return hasUnresolved ? null : resolved
93+
}
94+
95+
/**
96+
* Parse all FROM instructions from a Dockerfile using tree-sitter to extract base image references.
97+
* In multi-stage builds, every FROM line is returned so each image can be analyzed independently.
98+
* ARG substitutions are resolved using declared default values. FROM lines with unresolvable
99+
* variables (ARGs without defaults) are silently skipped.
56100
* @param {string} manifestContent the content of the Dockerfile
57-
* @returns {Promise<string>} the image reference from the last FROM instruction
58-
* @throws {Error} when no FROM instruction is found or when ARG substitution is used
101+
* @returns {Promise<string[]>} array of image references from all resolvable FROM instructions
102+
* @throws {Error} when no FROM instruction is found or when no FROM lines can be resolved
59103
*/
60-
export async function parseFromImage(manifestContent) {
61-
const [parser, fromQuery] = await Promise.all([getParser(), getFromQuery()])
104+
export async function parseAllFromImages(manifestContent) {
105+
const [parser, fromQuery, argQuery] = await Promise.all([getParser(), getFromQuery(), getArgQuery()])
62106
const tree = parser.parse(manifestContent)
63107
const matches = fromQuery.matches(tree.rootNode)
64108
if (matches.length === 0) {
65109
throw new Error('No FROM line found in Dockerfile')
66110
}
67-
const lastMatch = matches[matches.length - 1]
68-
const imageSpec = lastMatch.captures.find(c => c.name === 'image').node
69-
if (containsExpansion(imageSpec)) {
70-
throw new Error('Dockerfile uses ARG substitution in FROM line — cannot resolve variable references')
111+
const args = collectArgs(tree, argQuery)
112+
const images = []
113+
for (const match of matches) {
114+
const imageSpec = match.captures.find(c => c.name === 'image').node
115+
if (containsExpansion(imageSpec)) {
116+
const resolved = resolveArgs(imageSpec.text, args)
117+
if (resolved != null) {
118+
images.push(resolved)
119+
}
120+
continue
121+
}
122+
images.push(imageSpec.text)
123+
}
124+
if (images.length === 0) {
125+
throw new Error('Dockerfile uses ARG substitution in all FROM lines — cannot resolve variable references')
71126
}
72-
return imageSpec.text
127+
return images
73128
}
74129

75130
/**
76-
* Generate an image SBOM from a Dockerfile manifest using syft.
131+
* Generate image SBOMs for all FROM instructions in a Dockerfile manifest using syft.
132+
* Returns a batch result with a purl-to-SBOM map suitable for the batch-analysis endpoint.
77133
* @param {string} manifest path to the Dockerfile
78134
* @param {{}} [opts={}] optional various options to pass along the application
79-
* @returns {Promise<{ecosystem: string, content: string, contentType: string}>}
135+
* @returns {Promise<{ecosystem: string, content: string, contentType: string, batch: boolean}>}
80136
* @private
81137
*/
82138
async function getImageSBOM(manifest, opts = {}) {
83139
const manifestContent = fs.readFileSync(manifest, 'utf-8')
84-
const image = await parseFromImage(manifestContent)
85-
const imageRef = parseImageRef(image, opts)
86-
const sbom = generateImageSBOM(imageRef, opts)
140+
const images = await parseAllFromImages(manifestContent)
141+
const sbomByPurl = {}
142+
for (const image of images) {
143+
const imageRef = parseImageRef(image, opts)
144+
sbomByPurl[imageRef.getPackageURL().toString()] = generateImageSBOM(imageRef, opts)
145+
}
87146
return {
88147
ecosystem,
89-
content: JSON.stringify(sbom),
90-
contentType: 'application/vnd.cyclonedx+json'
148+
content: JSON.stringify(sbomByPurl),
149+
contentType: 'application/vnd.cyclonedx+json',
150+
batch: true
91151
}
92152
}
93153

0 commit comments

Comments
 (0)