Skip to content

Commit bf0257f

Browse files
a-orenclaude
andcommitted
feat(providers): add Dockerfile/Containerfile provider for image analysis
Add DockerfileProvider that parses FROM instructions to extract base image references and generates CycloneDX SBOMs via syft. Supports multi-stage builds (uses final FROM), suffixed filenames (Dockerfile.dev), multiple --flag tokens, and rejects ARG substitution and FROM scratch. Also normalize Docker Hub image references in ImageRef.getPackageURL() so bare names (node) and library-prefixed names (docker.io/library/node) produce the same PURL (docker.io/node), aligning with the JS client. Implements: TC-4938 Assisted-by: Claude Code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2db003f commit bf0257f

16 files changed

Lines changed: 440 additions & 1 deletion

File tree

src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,26 @@ void checkImageDigest() {
140140
}
141141
}
142142

143+
private static final String DOCKER_HUB_LIBRARY_PREFIX = "docker.io/library/";
144+
143145
// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci
144146
public PackageURL getPackageURL() throws MalformedPackageURLException {
145147
TreeMap<String, String> qualifiers = new TreeMap<>();
146148
var repositoryUrl = this.image.getNameWithoutTag();
147149
var simpleName = this.image.getSimpleName();
150+
151+
// Normalize Docker Hub image references so all forms produce the same PURL:
152+
// node → docker.io/node
153+
// docker.io/library/node → docker.io/node
154+
if (repositoryUrl != null) {
155+
var lower = repositoryUrl.toLowerCase();
156+
if (lower.equals(simpleName.toLowerCase())) {
157+
repositoryUrl = "docker.io/" + simpleName;
158+
} else if (lower.startsWith(DOCKER_HUB_LIBRARY_PREFIX)) {
159+
repositoryUrl = "docker.io/" + lower.substring(DOCKER_HUB_LIBRARY_PREFIX.length());
160+
}
161+
}
162+
148163
if (repositoryUrl != null && !repositoryUrl.equalsIgnoreCase(simpleName)) {
149164
qualifiers.put(REPOSITORY_QUALIFIER, repositoryUrl.toLowerCase());
150165
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2023-2025 Trustify Dependency Analytics Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
*
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package io.github.guacsec.trustifyda.providers;
18+
19+
import io.github.guacsec.trustifyda.Api;
20+
import io.github.guacsec.trustifyda.Provider;
21+
import io.github.guacsec.trustifyda.image.ImageRef;
22+
import io.github.guacsec.trustifyda.image.ImageUtils;
23+
import io.github.guacsec.trustifyda.tools.Ecosystem.Type;
24+
import java.io.IOException;
25+
import java.nio.file.Files;
26+
import java.nio.file.Path;
27+
import java.util.List;
28+
import java.util.regex.Pattern;
29+
30+
/**
31+
* Provider for Dockerfile and Containerfile manifests. Parses the FROM instruction to extract the
32+
* base image reference, then uses syft to generate a CycloneDX SBOM for analysis.
33+
*/
34+
public final class DockerfileProvider extends Provider {
35+
36+
private static final Pattern FROM_LINE_PATTERN =
37+
Pattern.compile("^FROM\\s+", Pattern.CASE_INSENSITIVE);
38+
39+
public DockerfileProvider(Path manifest) {
40+
super(Type.DOCKERFILE, manifest);
41+
}
42+
43+
@Override
44+
public Content provideStack() throws IOException {
45+
return generateSbomContent();
46+
}
47+
48+
@Override
49+
public Content provideComponent() throws IOException {
50+
return generateSbomContent();
51+
}
52+
53+
@Override
54+
public String readLicenseFromManifest() {
55+
return null;
56+
}
57+
58+
/**
59+
* Parses the manifest file to find the last FROM instruction and generates a CycloneDX SBOM for
60+
* the referenced image.
61+
*/
62+
private Content generateSbomContent() throws IOException {
63+
String imageReference = parseLastFromImage(manifestPath);
64+
ImageRef imageRef = ImageUtils.parseImageRef(imageReference);
65+
try {
66+
var sbomNode = ImageUtils.generateImageSBOM(imageRef);
67+
byte[] sbomBytes = objectMapper.writeValueAsBytes(sbomNode);
68+
return new Content(sbomBytes, Api.CYCLONEDX_MEDIA_TYPE);
69+
} catch (Exception e) {
70+
throw new IOException("Failed to generate SBOM for image: " + imageReference, e);
71+
}
72+
}
73+
74+
/**
75+
* Parses a Dockerfile/Containerfile and extracts the image reference from the last FROM
76+
* instruction. In multi-stage builds, the last FROM defines the final image.
77+
*
78+
* @param dockerfile path to the Dockerfile or Containerfile
79+
* @return the image reference string from the last FROM instruction
80+
* @throws IOException if the file cannot be read or contains no FROM instruction
81+
*/
82+
static String parseLastFromImage(Path dockerfile) throws IOException {
83+
List<String> lines = Files.readAllLines(dockerfile);
84+
String lastImage = null;
85+
for (String line : lines) {
86+
String trimmed = line.trim();
87+
var matcher = FROM_LINE_PATTERN.matcher(trimmed);
88+
if (matcher.find()) {
89+
// Strip the FROM keyword, then tokenize the remainder
90+
String remainder = trimmed.substring(matcher.end());
91+
String[] tokens = remainder.split("\\s+");
92+
// Skip all leading --flag tokens (e.g. --platform=linux/amd64 --some-flag=value)
93+
int i = 0;
94+
while (i < tokens.length && tokens[i].startsWith("--")) {
95+
i++;
96+
}
97+
if (i < tokens.length) {
98+
lastImage = tokens[i];
99+
}
100+
}
101+
}
102+
if (lastImage == null) {
103+
throw new IOException("No FROM instruction found in " + dockerfile);
104+
}
105+
if (lastImage.contains("${")) {
106+
throw new IOException(
107+
"Dockerfile uses ARG substitution in FROM line — cannot resolve variable references: "
108+
+ dockerfile);
109+
}
110+
if ("scratch".equals(lastImage)) {
111+
throw new IOException(
112+
"Dockerfile uses FROM scratch — no base image to analyze: " + dockerfile);
113+
}
114+
return lastImage;
115+
}
116+
}

src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import io.github.guacsec.trustifyda.Provider;
2020
import io.github.guacsec.trustifyda.providers.CargoProvider;
21+
import io.github.guacsec.trustifyda.providers.DockerfileProvider;
2122
import io.github.guacsec.trustifyda.providers.GoModulesProvider;
2223
import io.github.guacsec.trustifyda.providers.GradleProvider;
2324
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
@@ -37,7 +38,8 @@ public enum Type {
3738
GOLANG("golang"),
3839
PYTHON("pypi"),
3940
GRADLE("gradle"),
40-
CARGO("cargo");
41+
CARGO("cargo"),
42+
DOCKERFILE("oci");
4143

4244
final String type;
4345

@@ -55,6 +57,7 @@ public String getExecutableShortName() {
5557
case PYTHON -> "python";
5658
case GRADLE -> "gradle";
5759
case CARGO -> "cargo";
60+
case DOCKERFILE -> "syft";
5861
};
5962
}
6063

@@ -81,6 +84,9 @@ public static Provider getProvider(final Path manifestPath) {
8184

8285
private static Provider resolveProvider(final Path manifestPath) {
8386
var manifestFile = manifestPath.getFileName().toString();
87+
if (isDockerfile(manifestFile)) {
88+
return new DockerfileProvider(manifestPath);
89+
}
8490
return switch (manifestFile) {
8591
case "pom.xml" -> new JavaMavenProvider(manifestPath);
8692
case "package.json" -> JavaScriptProviderFactory.create(manifestPath);
@@ -93,4 +99,11 @@ private static Provider resolveProvider(final Path manifestPath) {
9399
throw new IllegalStateException(String.format("Unknown manifest file %s", manifestFile));
94100
};
95101
}
102+
103+
private static boolean isDockerfile(String filename) {
104+
return filename.equals("Dockerfile")
105+
|| filename.equals("Containerfile")
106+
|| filename.startsWith("Dockerfile.")
107+
|| filename.startsWith("Containerfile.");
108+
}
96109
}

src/test/java/io/github/guacsec/trustifyda/image/ImageRefTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,59 @@ void test_imageRef() throws MalformedPackageURLException {
6363
assertEquals(imageRef.hashCode(), imageRefPurl.hashCode());
6464
}
6565

66+
private static final String TEST_DIGEST =
67+
"sha256:333224a233db31852ac1085c6cd702016ab8aaf54cecde5c4bed5451d636adcf";
68+
69+
@Test
70+
void test_docker_hub_bare_name_normalized_in_purl() throws MalformedPackageURLException {
71+
var imageRef = new ImageRef("node:18@" + TEST_DIGEST, null);
72+
73+
var purl = imageRef.getPackageURL();
74+
75+
assertEquals("docker.io/node", purl.getQualifiers().get("repository_url"));
76+
assertEquals("node", purl.getName());
77+
}
78+
79+
@Test
80+
void test_docker_hub_library_prefix_normalized_in_purl() throws MalformedPackageURLException {
81+
var imageRef = new ImageRef("docker.io/library/node:18@" + TEST_DIGEST, null);
82+
83+
var purl = imageRef.getPackageURL();
84+
85+
assertEquals("docker.io/node", purl.getQualifiers().get("repository_url"));
86+
assertEquals("node", purl.getName());
87+
}
88+
89+
@Test
90+
void test_docker_hub_both_forms_produce_same_purl() throws MalformedPackageURLException {
91+
var bareRef = new ImageRef("node:18@" + TEST_DIGEST, null);
92+
var libraryRef = new ImageRef("docker.io/library/node:18@" + TEST_DIGEST, null);
93+
94+
assertEquals(
95+
bareRef.getPackageURL().getQualifiers().get("repository_url"),
96+
libraryRef.getPackageURL().getQualifiers().get("repository_url"));
97+
}
98+
99+
@Test
100+
void test_non_docker_hub_registry_unchanged_in_purl() throws MalformedPackageURLException {
101+
var imageRef =
102+
new ImageRef("registry.access.redhat.com/ubi9/ubi-minimal:9.4@" + TEST_DIGEST, null);
103+
104+
var purl = imageRef.getPackageURL();
105+
106+
assertEquals(
107+
"registry.access.redhat.com/ubi9/ubi-minimal", purl.getQualifiers().get("repository_url"));
108+
}
109+
110+
@Test
111+
void test_docker_hub_user_image_unchanged_in_purl() throws MalformedPackageURLException {
112+
var imageRef = new ImageRef("docker.io/myuser/myimage:latest@" + TEST_DIGEST, null);
113+
114+
var purl = imageRef.getPackageURL();
115+
116+
assertEquals("docker.io/myuser/myimage", purl.getQualifiers().get("repository_url"));
117+
}
118+
66119
@Test
67120
void test_check_image_digest() throws IOException {
68121
try (MockedStatic<Operations> mock = Mockito.mockStatic(Operations.class);

0 commit comments

Comments
 (0)