Skip to content

Commit b23e4ac

Browse files
a-orenclaude
andcommitted
feat(oci): normalize Docker Hub refs and use tree-sitter for Dockerfile parsing
Normalize Docker Hub image references in PURL generation so all FROM formats (node:22, docker.io/node:22, docker.io/library/node:22) produce the same repository_url=docker.io/node qualifier, matching the Hummingbird hardened image index. Migrate Dockerfile FROM parsing from regex to tree-sitter-containerfile for robust AST-based extraction. Rename TRUSTIFY_DA_RECOMMENDATIONS_ENABLED to TRUSTIFY_DA_RECOMMEND and use getCustom() for consistent env/option lookup. Support suffixed Dockerfile variants (e.g. Dockerfile.dev). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dd053df commit b23e4ac

10 files changed

Lines changed: 135 additions & 69 deletions

File tree

CONVENTIONS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
- **TypeScript**: Configuration present but code is primarily JavaScript with JSDoc
99
- **Node.js**: Requires Node >= 20.0.0, npm >= 11.5.1
1010
- **CLI**: `yargs` for command-line argument parsing
11-
- **Parsing Libraries**: `fast-xml-parser`, `fast-toml`, `smol-toml`, `tree-sitter-requirements`
11+
- **Parsing Libraries**: `fast-xml-parser`, `fast-toml`, `smol-toml`, `web-tree-sitter` with grammar packages (`tree-sitter-requirements`, `tree-sitter-gomod`, `tree-sitter-containerfile`)
12+
- **Prefer proper parsers over regex**: When parsing structured file formats (e.g., go.mod, requirements.txt, Dockerfile), use tree-sitter grammars rather than hand-written regex. Tree-sitter grammars already exist for several formats and provide a well-defined AST that handles edge cases. Follow the existing pattern: add the grammar package, create a `*_parser.js` module under `src/providers/`, copy the `.wasm` file in `pretest`/`postcompile` scripts, and use queries to extract data from the syntax tree.
1213

1314
## Code Style
1415

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ By default, The API algorithm will use native commands of PIP installer as data
695695
It's also possible to use the lightweight Python PIP utility [pipdeptree](https://pypi.org/project/pipdeptree/) as data source instead. In order to activate this, you need to set the environment variable/option `TRUSTIFY_DA_PIP_USE_DEP_TREE` to 'true'.
696696
697697
#### Toggle Red Hat Trusted Content recommendations
698-
Both the HTML-based report and JSON response will by default contain recommendations for migrating to Red Hat-based Trusted Content repositories. This feature can be disabled by setting `TRUSTIFY_DA_RECOMMENDATIONS_ENABLED` to 'false' via environment variables or options.
698+
Both the HTML-based report and JSON response will by default contain recommendations for migrating to Red Hat-based Trusted Content repositories. This feature can be disabled by setting `TRUSTIFY_DA_RECOMMEND` to 'false' via environment variables or options.
699699
700700
#### Additional CLI arguments
701701
For some ecosystems we support passing additional CLI arguments to the underlying tools. The following table outlines the supported ecosystems and the environment variable/option that configures this. Note that the arguments are expected to be in the format of a JSON array.

package-lock.json

Lines changed: 14 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@
4040
"test": "c8 npm run tests",
4141
"tests": "mocha --config .mocharc.json",
4242
"tests:rep": "mocha --reporter-option maxDiffSize=0 --reporter json > unit-tests-result.json",
43-
"pretest": "cp node_modules/tree-sitter-requirements/tree-sitter-requirements.wasm src/providers/tree-sitter-requirements.wasm && cp node_modules/tree-sitter-gomod/tree-sitter-gomod.wasm src/providers/tree-sitter-gomod.wasm",
43+
"pretest": "cp node_modules/tree-sitter-requirements/tree-sitter-requirements.wasm src/providers/tree-sitter-requirements.wasm && cp node_modules/tree-sitter-gomod/tree-sitter-gomod.wasm src/providers/tree-sitter-gomod.wasm && cp node_modules/tree-sitter-containerfile/tree-sitter-containerfile.wasm src/providers/tree-sitter-containerfile.wasm",
4444
"precompile": "rm -rf dist",
4545
"compile": "tsc -p tsconfig.json",
4646
"compile:dev": "tsc -p tsconfig.dev.json",
47-
"postcompile": "cp node_modules/tree-sitter-requirements/tree-sitter-requirements.wasm dist/src/providers/tree-sitter-requirements.wasm && cp node_modules/tree-sitter-gomod/tree-sitter-gomod.wasm dist/src/providers/tree-sitter-gomod.wasm"
47+
"postcompile": "cp node_modules/tree-sitter-requirements/tree-sitter-requirements.wasm dist/src/providers/tree-sitter-requirements.wasm && cp node_modules/tree-sitter-gomod/tree-sitter-gomod.wasm dist/src/providers/tree-sitter-gomod.wasm && cp node_modules/tree-sitter-containerfile/tree-sitter-containerfile.wasm dist/src/providers/tree-sitter-containerfile.wasm"
4848
},
4949
"dependencies": {
5050
"@babel/core": "^7.23.2",
@@ -61,25 +61,26 @@
6161
"p-limit": "^7.3.0",
6262
"packageurl-js": "~1.0.2",
6363
"smol-toml": "^1.6.0",
64+
"tree-sitter-containerfile": "^0.8.1",
6465
"tree-sitter-gomod": "github:strum355/tree-sitter-go-mod#56326f2ad478892ace58ff247a97d492a3cbcdda",
6566
"tree-sitter-requirements": "github:Strum355/tree-sitter-requirements#d0261ee76b84253997fe70d7d397e78c006c3801",
6667
"web-tree-sitter": "^0.26.7",
6768
"yargs": "^18.0.0"
6869
},
6970
"devDependencies": {
7071
"@babel/core": "^7.23.2",
72+
"@eslint/js": "^10.0.0",
7173
"@trustify-da/trustify-da-api-model": "^2.0.9",
7274
"@types/node": "^25.9.1",
7375
"@types/which": "^3.0.4",
7476
"babel-plugin-rewire": "^1.2.0",
7577
"c8": "^11.0.0",
7678
"chai": "^6.2.2",
77-
"@eslint/js": "^10.0.0",
7879
"eslint": "^10.4.1",
7980
"eslint-import-resolver-typescript": "^4.4.4",
8081
"eslint-plugin-import-x": "^4.16.2",
81-
"globals": "^17.6.0",
8282
"esmock": "^2.6.2",
83+
"globals": "^17.6.0",
8384
"mocha": "^11.7.6",
8485
"msw": "^2.12.7",
8586
"sinon": "^22.0.0",

src/analysis.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) {
4444
}, opts);
4545

4646
const finalUrl = new URL(`${url}/api/v5/analysis`);
47-
if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') {
47+
if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') {
4848
finalUrl.searchParams.append('recommend', 'false');
4949
}
5050

@@ -106,7 +106,7 @@ async function requestComponent(provider, manifest, url, opts = {}) {
106106
}, opts);
107107

108108
const finalUrl = new URL(`${url}/api/v5/analysis`);
109-
if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') {
109+
if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') {
110110
finalUrl.searchParams.append('recommend', 'false');
111111
}
112112

@@ -150,7 +150,7 @@ async function requestComponent(provider, manifest, url, opts = {}) {
150150
*/
151151
async function requestStackBatch(sbomByPurl, url, html = false, opts = {}) {
152152
const finalUrl = new URL(`${url}/api/v5/batch-analysis`)
153-
if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') {
153+
if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') {
154154
finalUrl.searchParams.append('recommend', 'false')
155155
}
156156

@@ -203,7 +203,7 @@ async function requestImages(imageRefs, url, html = false, opts = {}) {
203203
}
204204

205205
const finalUrl = new URL(`${url}/api/v5/batch-analysis`);
206-
if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') {
206+
if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') {
207207
finalUrl.searchParams.append('recommend', 'false');
208208
}
209209

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export {
6161
* TRUSTIFY_DA_PYTHON_PATH?: string | undefined,
6262
* TRUSTIFY_DA_PYTHON_VIRTUAL_ENV?: string | undefined,
6363
* TRUSTIFY_DA_PYTHON3_PATH?: string | undefined,
64-
* TRUSTIFY_DA_RECOMMENDATIONS_ENABLED?: string | undefined,
64+
* TRUSTIFY_DA_RECOMMEND?: string | undefined,
6565
* TRUSTIFY_DA_SKOPEO_CONFIG_PATH?: string | undefined,
6666
* TRUSTIFY_DA_SKOPEO_PATH?: string | undefined,
6767
* TRUSTIFY_DA_SYFT_CONFIG_PATH?: string | undefined,

src/oci_image/images.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,23 @@ export class ImageRef {
266266
getPackageURL() {
267267
/** @type {Object.<string, string>} */
268268
const qualifiers = {};
269-
const repositoryUrl = this.image.getNameWithoutTag();
269+
let repositoryUrl = this.image.getNameWithoutTag();
270270
const simpleName = this.image.getSimpleName();
271271

272+
// Normalize Docker Hub image references so all forms produce the same PURL:
273+
// node → docker.io/node
274+
// docker.io/library/node → docker.io/node
275+
if (repositoryUrl != null) {
276+
const lower = repositoryUrl.toLowerCase();
277+
if (lower === simpleName.toLowerCase()) {
278+
// Bare name (e.g. "node") — default to docker.io
279+
repositoryUrl = `docker.io/${simpleName}`;
280+
} else if (lower.startsWith('docker.io/library/')) {
281+
// Official image with library/ prefix — strip it
282+
repositoryUrl = `docker.io/${lower.substring('docker.io/library/'.length)}`;
283+
}
284+
}
285+
272286
if (repositoryUrl != null && repositoryUrl.toLowerCase() !== simpleName.toLowerCase()) {
273287
qualifiers[ImageRef.REPOSITORY_QUALIFIER] = repositoryUrl.toLowerCase();
274288
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
import { Language, Parser, Query } from 'web-tree-sitter';
4+
5+
const wasmUrl = new URL('./tree-sitter-containerfile.wasm', import.meta.url);
6+
7+
async function init() {
8+
await Parser.init();
9+
const wasmBytes = new Uint8Array(await readFile(wasmUrl));
10+
return await Language.load(wasmBytes);
11+
}
12+
13+
export async function getParser() {
14+
const language = await init();
15+
return new Parser().setLanguage(language);
16+
}
17+
18+
export async function getFromQuery() {
19+
const language = await init();
20+
return new Query(language, '(from_instruction (image_spec) @image)');
21+
}

src/providers/oci_dockerfile.js

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import fs from 'node:fs'
22

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

5+
import { getFromQuery, getParser } from './containerfile_parser.js'
6+
57
export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest, packageManagerName() { return 'oci' } }
68

79
/** @typedef {import('../provider').Provider} */
@@ -16,11 +18,12 @@ const ecosystem = 'oci'
1618

1719
/**
1820
* Check if the given manifest name is a Dockerfile or Containerfile.
21+
* Supports suffixed variants such as Dockerfile.dev or Containerfile.prod.
1922
* @param {string} manifestName the manifest file name to check
2023
* @returns {boolean} true if the manifest is a Dockerfile or Containerfile
2124
*/
2225
function isSupported(manifestName) {
23-
return manifestName === 'Dockerfile' || manifestName === 'Containerfile'
26+
return manifestName.startsWith('Dockerfile') || manifestName.startsWith('Containerfile')
2427
}
2528

2629
/**
@@ -30,47 +33,55 @@ function isSupported(manifestName) {
3033
function validateLockFile() { return true; }
3134

3235
/**
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
36+
* Check whether a syntax node contains any expansion (variable substitution) children.
37+
* @param {import('web-tree-sitter').SyntaxNode} node
38+
* @returns {boolean}
39+
* @private
3840
*/
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 [--flag=val ...] image [AS name]
46-
const tokens = trimmed.replace(/^FROM\s+/i, '').split(/\s+/)
47-
// Skip all leading --flag tokens (e.g. --platform=linux/amd64)
48-
let i = 0
49-
while (i < tokens.length && tokens[i].startsWith('--')) {
50-
i++
51-
}
52-
lastFrom = tokens[i] || null
41+
function containsExpansion(node) {
42+
if (node.type === 'expansion') {
43+
return true
44+
}
45+
for (let i = 0; i < node.childCount; i++) {
46+
if (containsExpansion(node.child(i))) {
47+
return true
5348
}
5449
}
55-
if (!lastFrom) {
50+
return false
51+
}
52+
53+
/**
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.
56+
* @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
59+
*/
60+
export async function parseFromImage(manifestContent) {
61+
const [parser, fromQuery] = await Promise.all([getParser(), getFromQuery()])
62+
const tree = parser.parse(manifestContent)
63+
const matches = fromQuery.matches(tree.rootNode)
64+
if (matches.length === 0) {
5665
throw new Error('No FROM line found in Dockerfile')
5766
}
58-
if (lastFrom.includes('${')) {
67+
const lastMatch = matches[matches.length - 1]
68+
const imageSpec = lastMatch.captures.find(c => c.name === 'image').node
69+
if (containsExpansion(imageSpec)) {
5970
throw new Error('Dockerfile uses ARG substitution in FROM line — cannot resolve variable references')
6071
}
61-
return lastFrom
72+
return imageSpec.text
6273
}
6374

6475
/**
6576
* Generate an image SBOM from a Dockerfile manifest using syft.
6677
* @param {string} manifest path to the Dockerfile
6778
* @param {{}} [opts={}] optional various options to pass along the application
68-
* @returns {{ecosystem: string, content: string, contentType: string}}
79+
* @returns {Promise<{ecosystem: string, content: string, contentType: string}>}
6980
* @private
7081
*/
71-
function getImageSBOM(manifest, opts = {}) {
82+
async function getImageSBOM(manifest, opts = {}) {
7283
const manifestContent = fs.readFileSync(manifest, 'utf-8')
73-
const image = parseFromImage(manifestContent)
84+
const image = await parseFromImage(manifestContent)
7485
const imageRef = parseImageRef(image, opts)
7586
const sbom = generateImageSBOM(imageRef, opts)
7687
return {
@@ -84,19 +95,19 @@ function getImageSBOM(manifest, opts = {}) {
8495
* Provide content and content type for Dockerfile component analysis.
8596
* @param {string} manifest path to the Dockerfile
8697
* @param {{}} [opts={}] optional various options to pass along the application
87-
* @returns {Provided}
98+
* @returns {Promise<Provided>}
8899
*/
89-
function provideComponent(manifest, opts = {}) {
100+
async function provideComponent(manifest, opts = {}) {
90101
return getImageSBOM(manifest, opts)
91102
}
92103

93104
/**
94105
* Provide content and content type for Dockerfile stack analysis.
95106
* @param {string} manifest path to the Dockerfile
96107
* @param {{}} [opts={}] optional various options to pass along the application
97-
* @returns {Provided}
108+
* @returns {Promise<Provided>}
98109
*/
99-
function provideStack(manifest, opts = {}) {
110+
async function provideStack(manifest, opts = {}) {
100111
return getImageSBOM(manifest, opts)
101112
}
102113

0 commit comments

Comments
 (0)