Skip to content

Commit 156bb77

Browse files
a-orenclaude
andauthored
fix(providers): analyze all FROM lines in multi-stage Dockerfiles via batch analysis (#577)
* 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 * fix(providers): handle quoted ARG defaults and skip duplicate image SBOMs Broaden the tree-sitter ARG query to match any node type (not just unquoted_string) so quoted defaults like ARG FOO="bar" are captured. Strip surrounding quotes in collectArgs. Also skip redundant generateImageSBOM calls when the same image appears in multiple FROM lines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be56bee commit 156bb77

4 files changed

Lines changed: 300 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: (_) @name default: (_) @default))');
26+
}

src/providers/oci_dockerfile.js

Lines changed: 94 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,118 @@ 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+
* Strip surrounding single or double quotes from a string.
55+
* @param {string} text
56+
* @returns {string}
57+
* @private
58+
*/
59+
function stripQuotes(text) {
60+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
61+
return text.slice(1, -1)
62+
}
63+
return text
64+
}
65+
66+
/**
67+
* Collect ARG key-value pairs from the Dockerfile AST.
68+
* Only ARGs with a default value are collected (ARGs without defaults cannot be resolved statically).
69+
* @param {import('web-tree-sitter').Tree} tree the parsed Dockerfile tree
70+
* @param {import('web-tree-sitter').Query} argQuery the tree-sitter query for arg_instruction nodes
71+
* @returns {Map<string, string>} map of ARG names to their default values
72+
* @private
73+
*/
74+
function collectArgs(tree, argQuery) {
75+
const args = new Map()
76+
for (const match of argQuery.matches(tree.rootNode)) {
77+
const name = match.captures.find(c => c.name === 'name')?.node.text
78+
const defaultValue = match.captures.find(c => c.name === 'default')?.node.text
79+
if (name && defaultValue) {
80+
args.set(name, stripQuotes(defaultValue))
81+
}
82+
}
83+
return args
84+
}
85+
86+
/**
87+
* Resolve ARG substitutions in an image spec text using the collected ARG map.
88+
* Replaces ${VAR} and $VAR patterns with their ARG default values.
89+
* @param {string} text the image spec text potentially containing variable references
90+
* @param {Map<string, string>} args map of ARG names to their default values
91+
* @returns {string|null} the resolved text, or null if any variable could not be resolved
92+
* @private
93+
*/
94+
function resolveArgs(text, args) {
95+
let resolved = text
96+
let hasUnresolved = false
97+
resolved = resolved.replace(/\$\{([^}]+)\}|\$([A-Za-z_]\w*)/g, (_match, braced, plain) => {
98+
const key = braced || plain
99+
if (args.has(key)) {
100+
return args.get(key)
101+
}
102+
hasUnresolved = true
103+
return _match
104+
})
105+
return hasUnresolved ? null : resolved
106+
}
107+
108+
/**
109+
* Parse all FROM instructions from a Dockerfile using tree-sitter to extract base image references.
110+
* In multi-stage builds, every FROM line is returned so each image can be analyzed independently.
111+
* ARG substitutions are resolved using declared default values. FROM lines with unresolvable
112+
* variables (ARGs without defaults) are silently skipped.
56113
* @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
114+
* @returns {Promise<string[]>} array of image references from all resolvable FROM instructions
115+
* @throws {Error} when no FROM instruction is found or when no FROM lines can be resolved
59116
*/
60-
export async function parseFromImage(manifestContent) {
61-
const [parser, fromQuery] = await Promise.all([getParser(), getFromQuery()])
117+
export async function parseAllFromImages(manifestContent) {
118+
const [parser, fromQuery, argQuery] = await Promise.all([getParser(), getFromQuery(), getArgQuery()])
62119
const tree = parser.parse(manifestContent)
63120
const matches = fromQuery.matches(tree.rootNode)
64121
if (matches.length === 0) {
65122
throw new Error('No FROM line found in Dockerfile')
66123
}
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')
124+
const args = collectArgs(tree, argQuery)
125+
const images = []
126+
for (const match of matches) {
127+
const imageSpec = match.captures.find(c => c.name === 'image').node
128+
if (containsExpansion(imageSpec)) {
129+
const resolved = resolveArgs(imageSpec.text, args)
130+
if (resolved != null) {
131+
images.push(resolved)
132+
}
133+
continue
134+
}
135+
images.push(imageSpec.text)
71136
}
72-
return imageSpec.text
137+
if (images.length === 0) {
138+
throw new Error('Dockerfile uses ARG substitution in all FROM lines — cannot resolve variable references')
139+
}
140+
return images
73141
}
74142

75143
/**
76-
* Generate an image SBOM from a Dockerfile manifest using syft.
144+
* Generate image SBOMs for all FROM instructions in a Dockerfile manifest using syft.
145+
* Returns a batch result with a purl-to-SBOM map suitable for the batch-analysis endpoint.
77146
* @param {string} manifest path to the Dockerfile
78147
* @param {{}} [opts={}] optional various options to pass along the application
79-
* @returns {Promise<{ecosystem: string, content: string, contentType: string}>}
148+
* @returns {Promise<{ecosystem: string, content: string, contentType: string, batch: boolean}>}
80149
* @private
81150
*/
82151
async function getImageSBOM(manifest, opts = {}) {
83152
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)
153+
const images = await parseAllFromImages(manifestContent)
154+
const sbomByPurl = {}
155+
for (const image of images) {
156+
const imageRef = parseImageRef(image, opts)
157+
const purl = imageRef.getPackageURL().toString()
158+
if (purl in sbomByPurl) continue
159+
sbomByPurl[purl] = generateImageSBOM(imageRef, opts)
160+
}
87161
return {
88162
ecosystem,
89-
content: JSON.stringify(sbom),
90-
contentType: 'application/vnd.cyclonedx+json'
163+
content: JSON.stringify(sbomByPurl),
164+
contentType: 'application/vnd.cyclonedx+json',
165+
batch: true
91166
}
92167
}
93168

0 commit comments

Comments
 (0)