Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) {
opts["source-manifest"] = Buffer.from(fs.readFileSync(manifest).toString()).toString('base64')
opts["manifest-type"] = path.parse(manifest).base
let provided = await provider.provideStack(manifest, opts) // throws error if content providing failed
if (provided.batch) {
return requestStackBatch(JSON.parse(provided.content), url, html, opts)
}
opts["source-manifest"] = ""
opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "stack-analysis"
let startTime = new Date()
Expand Down Expand Up @@ -88,6 +91,9 @@ async function requestComponent(provider, manifest, url, opts = {}) {
opts["source-manifest"] = Buffer.from(fs.readFileSync(manifest).toString()).toString('base64')

let provided = await provider.provideComponent(manifest, opts) // throws error if content providing failed
if (provided.batch) {
return requestStackBatch(JSON.parse(provided.content), url, false, opts)
}
opts["source-manifest"] = ""
opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "component-analysis"
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
Expand Down
5 changes: 5 additions & 0 deletions src/providers/containerfile_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ export async function getFromQuery() {
const language = await init();
return new Query(language, '(from_instruction (image_spec) @image)');
}

export async function getArgQuery() {
const language = await init();
return new Query(language, '(arg_instruction (arg_pair name: (unquoted_string) @name default: (unquoted_string) @default))');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): ARG query only captures unquoted defaults, missing valid quoted ARG defaults.

By restricting both name and default to unquoted_string, this misses valid cases like ARG FOO="bar" (or other representations tree-sitter may use). Those ARGs will be invisible to collectArgs, causing FROM lines that depend on them to be treated as unresolvable and skipped. Please broaden the matched node types for default (and possibly name) so quoted and alternative forms are included.

Suggested implementation:

export async function getArgQuery() {
	const language = await init();
	return new Query(
		language,
		'(arg_instruction (arg_pair name: (_) @name default: (_) @default))',
	);
}

If downstream code assumes @name and @default are always unquoted_string nodes, you may need to adjust it to handle other node types (e.g., string literals) by inspecting the captured nodes and normalizing their text (stripping quotes where needed).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — the current query correctly captures only ARGs with unquoted defaults, which is sufficient for static resolution. Broadening to wildcard node types would also require downstream normalization (quote stripping). No sub-task created.

@Strum355 Strum355 Jul 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although Ive never seen quoted ones in the wild (I was thinking of something else), we should ideally handle them. Removing the quotes will be necessary

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, handling them would make this more robust, and the extra normalization is pretty contained.

Concretely, I’d suggest two small tweaks:

  1. Broaden the query to accept any node type for name/default:
export async function getArgQuery() {
	const language = await init();
	return new Query(
		language,
		'(arg_instruction (arg_pair name: (_) @name default: (_) @default))',
	);
}
  1. Normalize in collectArgs so we strip surrounding quotes from defaults (and optionally names, though they’re usually bare identifiers):
function stripQuotes(text) {
	if (!text) return text;
	// Handle both single and double quoted strings
	if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith('\'') && text.endsWith('\''))) {
		return text.slice(1, -1);
	}
	return text;
}

function collectArgs(tree, argQuery) {
	const args = new Map();
	for (const match of argQuery.matches(tree.rootNode)) {
		const nameNode = match.captures.find(c => c.name === 'name')?.node;
		const defaultNode = match.captures.find(c => c.name === 'default')?.node;
		if (!nameNode || !defaultNode) continue;

		const name = nameNode.text; // typically unquoted identifier
		const defaultValue = stripQuotes(defaultNode.text);

		if (name && defaultValue) {
			args.set(name, defaultValue);
		}
	}
	return args;
}

That keeps the rest of the pipeline unchanged while covering ARG FOO="bar", ARG FOO='bar', and the existing unquoted cases.

If you’d like, I can also suggest a follow-up test case for a Dockerfile with quoted ARG defaults to ensure this path is exercised.

}
98 changes: 79 additions & 19 deletions src/providers/oci_dockerfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs'

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

import { getFromQuery, getParser } from './containerfile_parser.js'
import { getArgQuery, getFromQuery, getParser } from './containerfile_parser.js'

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

Expand Down Expand Up @@ -51,43 +51,103 @@ function containsExpansion(node) {
}

/**
* Parse the last FROM instruction from a Dockerfile using tree-sitter to extract the base image reference.
* In multi-stage builds, the last FROM represents the final stage.
* Collect ARG key-value pairs from the Dockerfile AST.
* Only ARGs with a default value are collected (ARGs without defaults cannot be resolved statically).
* @param {import('web-tree-sitter').Tree} tree the parsed Dockerfile tree
* @param {import('web-tree-sitter').Query} argQuery the tree-sitter query for arg_instruction nodes
* @returns {Map<string, string>} map of ARG names to their default values
* @private
*/
function collectArgs(tree, argQuery) {
const args = new Map()
for (const match of argQuery.matches(tree.rootNode)) {
const name = match.captures.find(c => c.name === 'name')?.node.text
const defaultValue = match.captures.find(c => c.name === 'default')?.node.text
if (name && defaultValue) {
args.set(name, defaultValue)
}
}
return args
}

/**
* Resolve ARG substitutions in an image spec text using the collected ARG map.
* Replaces ${VAR} and $VAR patterns with their ARG default values.
* @param {string} text the image spec text potentially containing variable references
* @param {Map<string, string>} args map of ARG names to their default values
* @returns {string|null} the resolved text, or null if any variable could not be resolved
* @private
*/
function resolveArgs(text, args) {
let resolved = text
let hasUnresolved = false
resolved = resolved.replace(/\$\{([^}]+)\}|\$([A-Za-z_]\w*)/g, (_match, braced, plain) => {
const key = braced || plain
if (args.has(key)) {
return args.get(key)
}
hasUnresolved = true
return _match
})
return hasUnresolved ? null : resolved
}

/**
* Parse all FROM instructions from a Dockerfile using tree-sitter to extract base image references.
* In multi-stage builds, every FROM line is returned so each image can be analyzed independently.
* ARG substitutions are resolved using declared default values. FROM lines with unresolvable
* variables (ARGs without defaults) are silently skipped.
* @param {string} manifestContent the content of the Dockerfile
* @returns {Promise<string>} the image reference from the last FROM instruction
* @throws {Error} when no FROM instruction is found or when ARG substitution is used
* @returns {Promise<string[]>} array of image references from all resolvable FROM instructions
* @throws {Error} when no FROM instruction is found or when no FROM lines can be resolved
*/
export async function parseFromImage(manifestContent) {
const [parser, fromQuery] = await Promise.all([getParser(), getFromQuery()])
export async function parseAllFromImages(manifestContent) {
const [parser, fromQuery, argQuery] = await Promise.all([getParser(), getFromQuery(), getArgQuery()])
const tree = parser.parse(manifestContent)
const matches = fromQuery.matches(tree.rootNode)
if (matches.length === 0) {
throw new Error('No FROM line found in Dockerfile')
}
const lastMatch = matches[matches.length - 1]
const imageSpec = lastMatch.captures.find(c => c.name === 'image').node
if (containsExpansion(imageSpec)) {
throw new Error('Dockerfile uses ARG substitution in FROM line — cannot resolve variable references')
const args = collectArgs(tree, argQuery)
const images = []
for (const match of matches) {
const imageSpec = match.captures.find(c => c.name === 'image').node
if (containsExpansion(imageSpec)) {
const resolved = resolveArgs(imageSpec.text, args)
if (resolved != null) {
images.push(resolved)
}
continue
}
images.push(imageSpec.text)
}
if (images.length === 0) {
throw new Error('Dockerfile uses ARG substitution in all FROM lines — cannot resolve variable references')
}
return imageSpec.text
return images
}

/**
* Generate an image SBOM from a Dockerfile manifest using syft.
* Generate image SBOMs for all FROM instructions in a Dockerfile manifest using syft.
* Returns a batch result with a purl-to-SBOM map suitable for the batch-analysis endpoint.
* @param {string} manifest path to the Dockerfile
* @param {{}} [opts={}] optional various options to pass along the application
* @returns {Promise<{ecosystem: string, content: string, contentType: string}>}
* @returns {Promise<{ecosystem: string, content: string, contentType: string, batch: boolean}>}
* @private
*/
async function getImageSBOM(manifest, opts = {}) {
const manifestContent = fs.readFileSync(manifest, 'utf-8')
const image = await parseFromImage(manifestContent)
const imageRef = parseImageRef(image, opts)
const sbom = generateImageSBOM(imageRef, opts)
const images = await parseAllFromImages(manifestContent)
const sbomByPurl = {}
for (const image of images) {
const imageRef = parseImageRef(image, opts)
sbomByPurl[imageRef.getPackageURL().toString()] = generateImageSBOM(imageRef, opts)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a containerfile lists the same image multiple times, this is rerun unnecessarily

}
return {
ecosystem,
content: JSON.stringify(sbom),
contentType: 'application/vnd.cyclonedx+json'
content: JSON.stringify(sbomByPurl),
contentType: 'application/vnd.cyclonedx+json',
batch: true
}
}

Expand Down
Loading
Loading