Skip to content

Commit 905c336

Browse files
committed
feat(providers): add Dockerfile/Containerfile provider for image analysis
Add a new provider that recognizes Dockerfile and Containerfile manifests for component and stack analysis. The provider parses FROM lines to extract the base image reference, then reuses generateImageSBOM to produce a CycloneDX SBOM. Multi-stage Dockerfiles use the final stage's FROM image. Implements TC-4937 Assisted-by: Claude Code
1 parent ab5949f commit 905c336

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

src/provider.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Javascript_bun from './providers/javascript_bun.js';
88
import Javascript_npm from './providers/javascript_npm.js';
99
import Javascript_pnpm from './providers/javascript_pnpm.js';
1010
import Javascript_yarn from './providers/javascript_yarn.js';
11+
import dockerfileProvider from './providers/oci_dockerfile.js'
1112
import pythonPipProvider from './providers/python_pip.js'
1213
import Python_pip_pyproject from './providers/python_pip_pyproject.js'
1314
import Python_poetry from './providers/python_poetry.js'
@@ -34,7 +35,8 @@ export const availableProviders = [
3435
new Python_poetry(),
3536
new Python_uv(),
3637
new Python_pip_pyproject(),
37-
rustCargoProvider]
38+
rustCargoProvider,
39+
dockerfileProvider]
3840

3941
/**
4042
* Match a provider by manifest type only (no lock file check). Used for license reading.

src/providers/oci_dockerfile.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import fs from 'node:fs'
2+
3+
import { generateImageSBOM, parseImageRef } from '../oci_image/utils.js'
4+
5+
export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest, packageManagerName() { return 'oci' } }
6+
7+
/** @typedef {import('../provider').Provider} */
8+
9+
/** @typedef {import('../provider').Provided} Provided */
10+
11+
/**
12+
* @type {string} ecosystem identifier for OCI image packages
13+
* @private
14+
*/
15+
const ecosystem = 'oci'
16+
17+
/**
18+
* Check if the given manifest name is a Dockerfile or Containerfile.
19+
* @param {string} manifestName the manifest file name to check
20+
* @returns {boolean} true if the manifest is a Dockerfile or Containerfile
21+
*/
22+
function isSupported(manifestName) {
23+
return manifestName === 'Dockerfile' || manifestName === 'Containerfile'
24+
}
25+
26+
/**
27+
* Dockerfiles have no lock file, so validation always passes.
28+
* @returns {boolean} always true
29+
*/
30+
function validateLockFile() { return true; }
31+
32+
/**
33+
* Parse the last FROM line from a Dockerfile to extract the base image reference.
34+
* In multi-stage builds, the last FROM represents the final stage.
35+
* @param {string} manifestContent the content of the Dockerfile
36+
* @returns {string} the image reference from the last FROM line
37+
* @throws {Error} when no FROM line is found in the Dockerfile
38+
*/
39+
export function parseFromImage(manifestContent) {
40+
const lines = manifestContent.split(/\r?\n/)
41+
let lastFrom = null
42+
for (const line of lines) {
43+
const trimmed = line.trim()
44+
if (/^FROM\s+/i.test(trimmed)) {
45+
// Extract image ref: FROM [--platform=...] image [AS name]
46+
const withoutFrom = trimmed.replace(/^FROM\s+/i, '')
47+
// Skip optional --platform flag
48+
const withoutFlags = withoutFrom.replace(/^--\S+\s+/, '')
49+
// Take only the image part (before AS alias)
50+
const parts = withoutFlags.split(/\s+/)
51+
lastFrom = parts[0]
52+
}
53+
}
54+
if (!lastFrom) {
55+
throw new Error('No FROM line found in Dockerfile')
56+
}
57+
return lastFrom
58+
}
59+
60+
/**
61+
* Generate an image SBOM from a Dockerfile manifest using syft.
62+
* @param {string} manifest path to the Dockerfile
63+
* @param {{}} [opts={}] optional various options to pass along the application
64+
* @returns {{ecosystem: string, content: string, contentType: string}}
65+
* @private
66+
*/
67+
function getImageSBOM(manifest, opts = {}) {
68+
const manifestContent = fs.readFileSync(manifest, 'utf-8')
69+
const image = parseFromImage(manifestContent)
70+
const imageRef = parseImageRef(image, opts)
71+
const sbom = generateImageSBOM(imageRef, opts)
72+
return {
73+
ecosystem,
74+
content: JSON.stringify(sbom),
75+
contentType: 'application/vnd.cyclonedx+json'
76+
}
77+
}
78+
79+
/**
80+
* Provide content and content type for Dockerfile component analysis.
81+
* @param {string} manifest path to the Dockerfile
82+
* @param {{}} [opts={}] optional various options to pass along the application
83+
* @returns {Provided}
84+
*/
85+
function provideComponent(manifest, opts = {}) {
86+
return getImageSBOM(manifest, opts)
87+
}
88+
89+
/**
90+
* Provide content and content type for Dockerfile stack analysis.
91+
* @param {string} manifest path to the Dockerfile
92+
* @param {{}} [opts={}] optional various options to pass along the application
93+
* @returns {Provided}
94+
*/
95+
function provideStack(manifest, opts = {}) {
96+
return getImageSBOM(manifest, opts)
97+
}
98+
99+
/**
100+
* Dockerfiles contain no license information.
101+
* @returns {null} always null
102+
*/
103+
function readLicenseFromManifest() { return null; }
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { expect } from 'chai'
2+
3+
import dockerfileProvider, { parseFromImage } from '../../src/providers/oci_dockerfile.js'
4+
5+
suite('testing the Dockerfile/Containerfile data provider', () => {
6+
7+
suite('isSupported', () => {
8+
/** Verifies that isSupported returns true for Dockerfile and Containerfile, false for others. */
9+
['Dockerfile', 'Containerfile'].forEach(name => {
10+
test(`returns true for ${name}`, () => {
11+
expect(dockerfileProvider.isSupported(name)).to.equal(true)
12+
})
13+
});
14+
15+
['package.json', 'go.mod', 'Cargo.toml', 'dockerfile', 'containerfile', 'Dockerfile.dev'].forEach(name => {
16+
test(`returns false for ${name}`, () => {
17+
expect(dockerfileProvider.isSupported(name)).to.equal(false)
18+
})
19+
})
20+
})
21+
22+
suite('validateLockFile', () => {
23+
/** Verifies that validateLockFile always returns true since Dockerfiles have no lock file. */
24+
test('always returns true', () => {
25+
expect(dockerfileProvider.validateLockFile()).to.equal(true)
26+
})
27+
})
28+
29+
suite('readLicenseFromManifest', () => {
30+
/** Verifies that readLicenseFromManifest returns null since Dockerfiles have no license info. */
31+
test('returns null', () => {
32+
expect(dockerfileProvider.readLicenseFromManifest()).to.equal(null)
33+
})
34+
})
35+
36+
suite('packageManagerName', () => {
37+
/** Verifies that packageManagerName returns oci. */
38+
test('returns oci', () => {
39+
expect(dockerfileProvider.packageManagerName()).to.equal('oci')
40+
})
41+
})
42+
43+
suite('parseFromImage', () => {
44+
/** Verifies that a single FROM line extracts the correct image reference. */
45+
test('extracts image from single-stage Dockerfile', () => {
46+
const content = 'FROM node:18\nRUN npm install\n'
47+
expect(parseFromImage(content)).to.equal('node:18')
48+
})
49+
50+
/** Verifies that the last FROM line is used in multi-stage Dockerfiles. */
51+
test('uses last FROM in multi-stage Dockerfile', () => {
52+
const content = [
53+
'FROM node:18 AS builder',
54+
'RUN npm run build',
55+
'',
56+
'FROM nginx:alpine',
57+
'COPY --from=builder /app/dist /usr/share/nginx/html',
58+
].join('\n')
59+
expect(parseFromImage(content)).to.equal('nginx:alpine')
60+
})
61+
62+
/** Verifies that --platform flags are skipped when parsing FROM lines. */
63+
test('handles --platform flag', () => {
64+
const content = 'FROM --platform=linux/amd64 ubuntu:22.04\n'
65+
expect(parseFromImage(content)).to.equal('ubuntu:22.04')
66+
})
67+
68+
/** Verifies that image references with digests are parsed correctly. */
69+
test('handles image with digest', () => {
70+
const content = 'FROM httpd@sha256:abc123\n'
71+
expect(parseFromImage(content)).to.equal('httpd@sha256:abc123')
72+
})
73+
74+
/** Verifies that an error is thrown when no FROM line is present. */
75+
test('throws when no FROM line found', () => {
76+
const content = 'RUN echo hello\n'
77+
expect(() => parseFromImage(content)).to.throw('No FROM line found in Dockerfile')
78+
})
79+
80+
/** Verifies that FROM line parsing is case-insensitive. */
81+
test('handles case-insensitive FROM keyword', () => {
82+
const content = 'from alpine:3.18\n'
83+
expect(parseFromImage(content)).to.equal('alpine:3.18')
84+
})
85+
86+
/** Verifies that comment lines and blank lines are ignored. */
87+
test('ignores comments and blank lines', () => {
88+
const content = [
89+
'# This is a comment',
90+
'',
91+
'FROM registry.example.com/myapp:latest',
92+
].join('\n')
93+
expect(parseFromImage(content)).to.equal('registry.example.com/myapp:latest')
94+
})
95+
})
96+
})

0 commit comments

Comments
 (0)