Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion src/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Javascript_bun from './providers/javascript_bun.js';
import Javascript_npm from './providers/javascript_npm.js';
import Javascript_pnpm from './providers/javascript_pnpm.js';
import Javascript_yarn from './providers/javascript_yarn.js';
import dockerfileProvider from './providers/oci_dockerfile.js'
import pythonPipProvider from './providers/python_pip.js'
import Python_pip_pyproject from './providers/python_pip_pyproject.js'
import Python_poetry from './providers/python_poetry.js'
Expand All @@ -34,7 +35,8 @@ export const availableProviders = [
new Python_poetry(),
new Python_uv(),
new Python_pip_pyproject(),
rustCargoProvider]
rustCargoProvider,
dockerfileProvider]

/**
* Match a provider by manifest type only (no lock file check). Used for license reading.
Expand Down
103 changes: 103 additions & 0 deletions src/providers/oci_dockerfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import fs from 'node:fs'

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

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

/** @typedef {import('../provider').Provider} */

/** @typedef {import('../provider').Provided} Provided */

/**
* @type {string} ecosystem identifier for OCI image packages
* @private
*/
const ecosystem = 'oci'

/**
* Check if the given manifest name is a Dockerfile or Containerfile.
* @param {string} manifestName the manifest file name to check
* @returns {boolean} true if the manifest is a Dockerfile or Containerfile
*/
function isSupported(manifestName) {
return manifestName === 'Dockerfile' || manifestName === 'Containerfile'
Comment thread
a-oren marked this conversation as resolved.
Outdated
}

/**
* Dockerfiles have no lock file, so validation always passes.
* @returns {boolean} always true
*/
function validateLockFile() { return true; }

/**
* Parse the last FROM line from a Dockerfile to extract the base image reference.
* In multi-stage builds, the last FROM represents the final stage.
* @param {string} manifestContent the content of the Dockerfile
* @returns {string} the image reference from the last FROM line
* @throws {Error} when no FROM line is found in the Dockerfile
*/
export function parseFromImage(manifestContent) {
const lines = manifestContent.split(/\r?\n/)
let lastFrom = null
for (const line of lines) {
const trimmed = line.trim()
if (/^FROM\s+/i.test(trimmed)) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
// Extract image ref: FROM [--platform=...] image [AS name]
const withoutFrom = trimmed.replace(/^FROM\s+/i, '')
// Skip optional --platform flag
const withoutFlags = withoutFrom.replace(/^--\S+\s+/, '')
// Take only the image part (before AS alias)
const parts = withoutFlags.split(/\s+/)
lastFrom = parts[0]
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
}
if (!lastFrom) {
throw new Error('No FROM line found in Dockerfile')
}
return lastFrom
}

/**
* Generate an image SBOM from a Dockerfile manifest using syft.
* @param {string} manifest path to the Dockerfile
* @param {{}} [opts={}] optional various options to pass along the application
* @returns {{ecosystem: string, content: string, contentType: string}}
* @private
*/
function getImageSBOM(manifest, opts = {}) {
const manifestContent = fs.readFileSync(manifest, 'utf-8')
const image = parseFromImage(manifestContent)
const imageRef = parseImageRef(image, opts)
const sbom = generateImageSBOM(imageRef, opts)
return {
ecosystem,
content: JSON.stringify(sbom),
contentType: 'application/vnd.cyclonedx+json'
}
}

/**
* Provide content and content type for Dockerfile component analysis.
* @param {string} manifest path to the Dockerfile
* @param {{}} [opts={}] optional various options to pass along the application
* @returns {Provided}
*/
function provideComponent(manifest, opts = {}) {
return getImageSBOM(manifest, opts)
}

/**
* Provide content and content type for Dockerfile stack analysis.
* @param {string} manifest path to the Dockerfile
* @param {{}} [opts={}] optional various options to pass along the application
* @returns {Provided}
*/
function provideStack(manifest, opts = {}) {
return getImageSBOM(manifest, opts)
}

/**
* Dockerfiles contain no license information.
* @returns {null} always null
*/
function readLicenseFromManifest() { return null; }
96 changes: 96 additions & 0 deletions test/providers/oci_dockerfile.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect } from 'chai'

import dockerfileProvider, { parseFromImage } from '../../src/providers/oci_dockerfile.js'

suite('testing the Dockerfile/Containerfile data provider', () => {

suite('isSupported', () => {
/** Verifies that isSupported returns true for Dockerfile and Containerfile, false for others. */
['Dockerfile', 'Containerfile'].forEach(name => {
test(`returns true for ${name}`, () => {
expect(dockerfileProvider.isSupported(name)).to.equal(true)
})
});

['package.json', 'go.mod', 'Cargo.toml', 'dockerfile', 'containerfile', 'Dockerfile.dev'].forEach(name => {
test(`returns false for ${name}`, () => {
expect(dockerfileProvider.isSupported(name)).to.equal(false)
})
})
})

suite('validateLockFile', () => {
/** Verifies that validateLockFile always returns true since Dockerfiles have no lock file. */
test('always returns true', () => {
expect(dockerfileProvider.validateLockFile()).to.equal(true)
})
})

suite('readLicenseFromManifest', () => {
/** Verifies that readLicenseFromManifest returns null since Dockerfiles have no license info. */
test('returns null', () => {
expect(dockerfileProvider.readLicenseFromManifest()).to.equal(null)
})
})

suite('packageManagerName', () => {
/** Verifies that packageManagerName returns oci. */
test('returns oci', () => {
expect(dockerfileProvider.packageManagerName()).to.equal('oci')
})
})

suite('parseFromImage', () => {
/** Verifies that a single FROM line extracts the correct image reference. */
test('extracts image from single-stage Dockerfile', () => {
const content = 'FROM node:18\nRUN npm install\n'
expect(parseFromImage(content)).to.equal('node:18')
})

/** Verifies that the last FROM line is used in multi-stage Dockerfiles. */
test('uses last FROM in multi-stage Dockerfile', () => {
const content = [
'FROM node:18 AS builder',
'RUN npm run build',
'',
'FROM nginx:alpine',
'COPY --from=builder /app/dist /usr/share/nginx/html',
].join('\n')
expect(parseFromImage(content)).to.equal('nginx:alpine')
})

/** Verifies that --platform flags are skipped when parsing FROM lines. */
test('handles --platform flag', () => {
const content = 'FROM --platform=linux/amd64 ubuntu:22.04\n'
expect(parseFromImage(content)).to.equal('ubuntu:22.04')
})

/** Verifies that image references with digests are parsed correctly. */
test('handles image with digest', () => {
const content = 'FROM httpd@sha256:abc123\n'
expect(parseFromImage(content)).to.equal('httpd@sha256:abc123')
})

/** Verifies that an error is thrown when no FROM line is present. */
test('throws when no FROM line found', () => {
const content = 'RUN echo hello\n'
expect(() => parseFromImage(content)).to.throw('No FROM line found in Dockerfile')
})

/** Verifies that FROM line parsing is case-insensitive. */
test('handles case-insensitive FROM keyword', () => {
const content = 'from alpine:3.18\n'
expect(parseFromImage(content)).to.equal('alpine:3.18')
})

/** Verifies that comment lines and blank lines are ignored. */
test('ignores comments and blank lines', () => {
const content = [
'# This is a comment',
'',
'FROM registry.example.com/myapp:latest',
].join('\n')
expect(parseFromImage(content)).to.equal('registry.example.com/myapp:latest')
})
})
})
Loading