@@ -2,7 +2,7 @@ import fs from 'node:fs'
22
33import { 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
77export 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 - Z a - 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 */
82151async 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