diff --git a/src/analysis.js b/src/analysis.js index 512ea08e..c2cea44d 100644 --- a/src/analysis.js +++ b/src/analysis.js @@ -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() @@ -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") { diff --git a/src/providers/containerfile_parser.js b/src/providers/containerfile_parser.js index 5ebfc90d..e123f635 100644 --- a/src/providers/containerfile_parser.js +++ b/src/providers/containerfile_parser.js @@ -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))'); +} diff --git a/src/providers/oci_dockerfile.js b/src/providers/oci_dockerfile.js index 66a395eb..fba7d648 100644 --- a/src/providers/oci_dockerfile.js +++ b/src/providers/oci_dockerfile.js @@ -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' } } @@ -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} 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} 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} the image reference from the last FROM instruction - * @throws {Error} when no FROM instruction is found or when ARG substitution is used + * @returns {Promise} 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) + } return { ecosystem, - content: JSON.stringify(sbom), - contentType: 'application/vnd.cyclonedx+json' + content: JSON.stringify(sbomByPurl), + contentType: 'application/vnd.cyclonedx+json', + batch: true } } diff --git a/test/providers/oci_dockerfile.test.js b/test/providers/oci_dockerfile.test.js index c6636fc8..6ddd2794 100644 --- a/test/providers/oci_dockerfile.test.js +++ b/test/providers/oci_dockerfile.test.js @@ -1,6 +1,7 @@ import { expect } from 'chai' +import esmock from 'esmock' -import dockerfileProvider, { parseFromImage } from '../../src/providers/oci_dockerfile.js' +import dockerfileProvider, { parseAllFromImages } from '../../src/providers/oci_dockerfile.js' suite('testing the Dockerfile/Containerfile data provider', () => { @@ -40,15 +41,16 @@ suite('testing the Dockerfile/Containerfile data provider', () => { }) }) - suite('parseFromImage', () => { - /** Verifies that a single FROM line extracts the correct image reference. */ - test('extracts image from single-stage Dockerfile', async () => { + suite('parseAllFromImages', () => { + /** Verifies that a single FROM line returns a single-element array. */ + test('returns single-element array for single-FROM Dockerfile', async () => { const content = 'FROM node:18\nRUN npm install\n' - expect(await parseFromImage(content)).to.equal('node:18') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['node:18']) }) - /** Verifies that the last FROM line is used in multi-stage Dockerfiles. */ - test('uses last FROM in multi-stage Dockerfile', async () => { + /** Verifies that all FROM lines are returned from a multi-stage Dockerfile. */ + test('returns all image refs from multi-stage Dockerfile', async () => { const content = [ 'FROM node:18 AS builder', 'RUN npm run build', @@ -56,35 +58,74 @@ suite('testing the Dockerfile/Containerfile data provider', () => { 'FROM nginx:alpine', 'COPY --from=builder /app/dist /usr/share/nginx/html', ].join('\n') - expect(await parseFromImage(content)).to.equal('nginx:alpine') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['node:18', 'nginx:alpine']) + }) + + /** Verifies that FROM lines with ARG substitution are resolved using declared defaults. */ + test('resolves ARG substitution in FROM lines using declared defaults', async () => { + const content = [ + 'ARG BASE_IMAGE=ubuntu:22.04', + 'FROM ${BASE_IMAGE} AS base', + 'RUN echo hello', + '', + 'FROM alpine:3.18', + 'COPY --from=base /app /app', + ].join('\n') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['ubuntu:22.04', 'alpine:3.18']) + }) + + /** Verifies that FROM lines with unresolvable ARGs (no default) are skipped. */ + test('skips FROM lines with ARG without default value', async () => { + const content = [ + 'ARG BASE_IMAGE', + 'FROM ${BASE_IMAGE} AS base', + 'RUN echo hello', + '', + 'FROM alpine:3.18', + 'COPY --from=base /app /app', + ].join('\n') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['alpine:3.18']) }) /** Verifies that a single --platform flag is skipped when parsing FROM lines. */ test('handles --platform flag', async () => { const content = 'FROM --platform=linux/amd64 ubuntu:22.04\n' - expect(await parseFromImage(content)).to.equal('ubuntu:22.04') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['ubuntu:22.04']) }) /** Verifies that multiple flags before the image reference are all skipped. */ test('handles multiple flags before image', async () => { const content = 'FROM --platform=linux/amd64 --some-flag=value ubuntu:22.04 AS base\n' - expect(await parseFromImage(content)).to.equal('ubuntu:22.04') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['ubuntu:22.04']) }) /** Verifies that image references with digests are parsed correctly. */ test('handles image with digest', async () => { const content = 'FROM httpd@sha256:abc123\n' - expect(await parseFromImage(content)).to.equal('httpd@sha256:abc123') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['httpd@sha256:abc123']) }) - /** Verifies that ARG-substituted FROM targets are rejected with a clear error. */ - test('throws when FROM target uses ARG substitution', async () => { + /** Verifies that ARG with default resolves successfully in a single FROM. */ + test('resolves single FROM with ARG default', async () => { const content = 'ARG BASE_IMAGE=ubuntu:22.04\nFROM ${BASE_IMAGE}\n' + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['ubuntu:22.04']) + }) + + /** Verifies that an error is thrown when all FROM lines use unresolvable ARG substitution. */ + test('throws when all FROM lines use ARG without defaults', async () => { + const content = 'ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\n' try { - await parseFromImage(content) + await parseAllFromImages(content) expect.fail('should have thrown') } catch (e) { - expect(e.message).to.include('Dockerfile uses ARG substitution in FROM line') + expect(e.message).to.include('Dockerfile uses ARG substitution in all FROM lines') } }) @@ -92,7 +133,7 @@ suite('testing the Dockerfile/Containerfile data provider', () => { test('throws when no FROM line found', async () => { const content = 'RUN echo hello\n' try { - await parseFromImage(content) + await parseAllFromImages(content) expect.fail('should have thrown') } catch (e) { expect(e.message).to.include('No FROM line found in Dockerfile') @@ -102,7 +143,8 @@ suite('testing the Dockerfile/Containerfile data provider', () => { /** Verifies that FROM line parsing is case-insensitive. */ test('handles case-insensitive FROM keyword', async () => { const content = 'from alpine:3.18\n' - expect(await parseFromImage(content)).to.equal('alpine:3.18') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['alpine:3.18']) }) /** Verifies that comment lines and blank lines are ignored. */ @@ -112,7 +154,128 @@ suite('testing the Dockerfile/Containerfile data provider', () => { '', 'FROM registry.example.com/myapp:latest', ].join('\n') - expect(await parseFromImage(content)).to.equal('registry.example.com/myapp:latest') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['registry.example.com/myapp:latest']) + }) + + /** Verifies that a three-stage Dockerfile returns all three image refs. */ + test('returns all images from three-stage Dockerfile', async () => { + const content = [ + 'FROM golang:1.21 AS build', + 'RUN go build -o app', + '', + 'FROM node:20 AS frontend', + 'RUN npm run build', + '', + 'FROM alpine:3.19', + 'COPY --from=build /app /app', + 'COPY --from=frontend /dist /dist', + ].join('\n') + const result = await parseAllFromImages(content) + expect(result).to.deep.equal(['golang:1.21', 'node:20', 'alpine:3.19']) + }) + }) + + suite('provideStack / provideComponent batch output', () => { + /** Verifies that provideStack returns batch format with batch: true flag. */ + test('provideStack returns batch format with batch flag', async () => { + // Given a mock Dockerfile with two FROM stages + const fakeSbom1 = { metadata: { component: { purl: 'pkg:oci/node@18' } } } + const fakeSbom2 = { metadata: { component: { purl: 'pkg:oci/nginx@alpine' } } } + let sbomCallIndex = 0 + + const mockedProvider = await esmock('../../src/providers/oci_dockerfile.js', { + 'node:fs': { + readFileSync: () => 'FROM node:18 AS builder\nFROM nginx:alpine\n' + }, + '../../src/oci_image/utils.js': { + parseImageRef: (image) => ({ + getPackageURL: () => ({ toString: () => `pkg:oci/${image}` }) + }), + generateImageSBOM: () => { + return sbomCallIndex++ === 0 ? fakeSbom1 : fakeSbom2 + } + } + }) + + // When calling provideStack + const result = await mockedProvider.default.provideStack('/fake/Dockerfile') + + // Then the result should have batch format + expect(result.ecosystem).to.equal('oci') + expect(result.contentType).to.equal('application/vnd.cyclonedx+json') + expect(result.batch).to.equal(true) + + const parsed = JSON.parse(result.content) + expect(Object.keys(parsed)).to.have.lengthOf(2) + expect(parsed['pkg:oci/node:18']).to.deep.equal(fakeSbom1) + expect(parsed['pkg:oci/nginx:alpine']).to.deep.equal(fakeSbom2) + }) + + /** Verifies that provideComponent returns batch format with batch: true flag. */ + test('provideComponent returns batch format with batch flag', async () => { + // Given a mock Dockerfile with a single FROM stage + const fakeSbom = { metadata: { component: { purl: 'pkg:oci/alpine@3.19' } } } + + const mockedProvider = await esmock('../../src/providers/oci_dockerfile.js', { + 'node:fs': { + readFileSync: () => 'FROM alpine:3.19\n' + }, + '../../src/oci_image/utils.js': { + parseImageRef: (image) => ({ + getPackageURL: () => ({ toString: () => `pkg:oci/${image}` }) + }), + generateImageSBOM: () => fakeSbom + } + }) + + // When calling provideComponent + const result = await mockedProvider.default.provideComponent('/fake/Dockerfile') + + // Then the result should have batch format with one entry + expect(result.ecosystem).to.equal('oci') + expect(result.contentType).to.equal('application/vnd.cyclonedx+json') + expect(result.batch).to.equal(true) + + const parsed = JSON.parse(result.content) + expect(Object.keys(parsed)).to.have.lengthOf(1) + expect(parsed['pkg:oci/alpine:3.19']).to.deep.equal(fakeSbom) + }) + + /** Verifies that batch output contains correct purl keys mapped to SBOM objects. */ + test('batch output maps purl keys to correct SBOM objects', async () => { + // Given a mock Dockerfile with three FROM stages + const sboms = { + 'golang:1.21': { metadata: { component: { name: 'golang' } } }, + 'node:20': { metadata: { component: { name: 'node' } } }, + 'alpine:3.19': { metadata: { component: { name: 'alpine' } } } + } + + const mockedProvider = await esmock('../../src/providers/oci_dockerfile.js', { + 'node:fs': { + readFileSync: () => 'FROM golang:1.21 AS build\nFROM node:20 AS frontend\nFROM alpine:3.19\n' + }, + '../../src/oci_image/utils.js': { + parseImageRef: (image) => ({ + getPackageURL: () => ({ toString: () => `pkg:oci/${image}` }) + }), + generateImageSBOM: (imageRef) => { + const purl = imageRef.getPackageURL().toString() + const image = purl.replace('pkg:oci/', '') + return sboms[image] + } + } + }) + + // When calling provideStack + const result = await mockedProvider.default.provideStack('/fake/Dockerfile') + + // Then each purl key should map to the correct SBOM + const parsed = JSON.parse(result.content) + expect(Object.keys(parsed)).to.have.lengthOf(3) + expect(parsed['pkg:oci/golang:1.21'].metadata.component.name).to.equal('golang') + expect(parsed['pkg:oci/node:20'].metadata.component.name).to.equal('node') + expect(parsed['pkg:oci/alpine:3.19'].metadata.component.name).to.equal('alpine') }) }) })