Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,26 @@ void checkImageDigest() {
}
}

private static final String DOCKER_HUB_LIBRARY_PREFIX = "docker.io/library/";

// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci
public PackageURL getPackageURL() throws MalformedPackageURLException {
TreeMap<String, String> qualifiers = new TreeMap<>();
var repositoryUrl = this.image.getNameWithoutTag();
var simpleName = this.image.getSimpleName();

// Normalize Docker Hub image references so all forms produce the same PURL:
// node → docker.io/node
// docker.io/library/node → docker.io/node
if (repositoryUrl != null) {
var lower = repositoryUrl.toLowerCase();
if (lower.equals(simpleName.toLowerCase())) {
repositoryUrl = "docker.io/" + simpleName;
} else if (lower.startsWith(DOCKER_HUB_LIBRARY_PREFIX)) {
repositoryUrl = "docker.io/" + lower.substring(DOCKER_HUB_LIBRARY_PREFIX.length());
}
}

if (repositoryUrl != null && !repositoryUrl.equalsIgnoreCase(simpleName)) {
qualifiers.put(REPOSITORY_QUALIFIER, repositoryUrl.toLowerCase());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2023-2025 Trustify Dependency Analytics Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.guacsec.trustifyda.providers;

import io.github.guacsec.trustifyda.Api;
import io.github.guacsec.trustifyda.Provider;
import io.github.guacsec.trustifyda.image.ImageRef;
import io.github.guacsec.trustifyda.image.ImageUtils;
import io.github.guacsec.trustifyda.tools.Ecosystem.Type;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;

/**
* Provider for Dockerfile and Containerfile manifests. Parses the FROM instruction to extract the
* base image reference, then uses syft to generate a CycloneDX SBOM for analysis.
*/
public final class DockerfileProvider extends Provider {

private static final Pattern FROM_LINE_PATTERN =
Pattern.compile("^FROM\\s+", Pattern.CASE_INSENSITIVE);

public DockerfileProvider(Path manifest) {
super(Type.DOCKERFILE, manifest);
}

@Override
public Content provideStack() throws IOException {
return generateSbomContent();
}

@Override
public Content provideComponent() throws IOException {
return generateSbomContent();
}

@Override
public String readLicenseFromManifest() {
return null;
}

/**
* Parses the manifest file to find the last FROM instruction and generates a CycloneDX SBOM for
* the referenced image.
*/
private Content generateSbomContent() throws IOException {
String imageReference = parseLastFromImage(manifestPath);
ImageRef imageRef = ImageUtils.parseImageRef(imageReference);
try {
var sbomNode = ImageUtils.generateImageSBOM(imageRef);
byte[] sbomBytes = objectMapper.writeValueAsBytes(sbomNode);
return new Content(sbomBytes, Api.CYCLONEDX_MEDIA_TYPE);
} catch (Exception e) {
throw new IOException("Failed to generate SBOM for image: " + imageReference, e);
}
}

/**
* Parses a Dockerfile/Containerfile and extracts the image reference from the last FROM
* instruction. In multi-stage builds, the last FROM defines the final image.
*
* @param dockerfile path to the Dockerfile or Containerfile
* @return the image reference string from the last FROM instruction
* @throws IOException if the file cannot be read or contains no FROM instruction
*/
static String parseLastFromImage(Path dockerfile) throws IOException {
List<String> lines = Files.readAllLines(dockerfile);
String lastImage = null;
for (String line : lines) {
String trimmed = line.trim();
var matcher = FROM_LINE_PATTERN.matcher(trimmed);
if (matcher.find()) {
// Strip the FROM keyword, then tokenize the remainder
String remainder = trimmed.substring(matcher.end());
String[] tokens = remainder.split("\\s+");
// Skip all leading --flag tokens (e.g. --platform=linux/amd64 --some-flag=value)
int i = 0;
while (i < tokens.length && tokens[i].startsWith("--")) {
i++;
}
if (i < tokens.length) {
lastImage = tokens[i];
}
}
}
if (lastImage == null) {
throw new IOException("No FROM instruction found in " + dockerfile);
}
if (lastImage.contains("${")) {
throw new IOException(
"Dockerfile uses ARG substitution in FROM line — cannot resolve variable references: "
+ dockerfile);
}
if ("scratch".equals(lastImage)) {
throw new IOException(
"Dockerfile uses FROM scratch — no base image to analyze: " + dockerfile);
}
return lastImage;
}
}
15 changes: 14 additions & 1 deletion src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import io.github.guacsec.trustifyda.Provider;
import io.github.guacsec.trustifyda.providers.CargoProvider;
import io.github.guacsec.trustifyda.providers.DockerfileProvider;
import io.github.guacsec.trustifyda.providers.GoModulesProvider;
import io.github.guacsec.trustifyda.providers.GradleProvider;
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
Expand All @@ -37,7 +38,8 @@ public enum Type {
GOLANG("golang"),
PYTHON("pypi"),
GRADLE("gradle"),
CARGO("cargo");
CARGO("cargo"),
DOCKERFILE("oci");

final String type;

Expand All @@ -55,6 +57,7 @@ public String getExecutableShortName() {
case PYTHON -> "python";
case GRADLE -> "gradle";
case CARGO -> "cargo";
case DOCKERFILE -> "syft";
};
}

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

private static Provider resolveProvider(final Path manifestPath) {
var manifestFile = manifestPath.getFileName().toString();
if (isDockerfile(manifestFile)) {
return new DockerfileProvider(manifestPath);
}
return switch (manifestFile) {
case "pom.xml" -> new JavaMavenProvider(manifestPath);
case "package.json" -> JavaScriptProviderFactory.create(manifestPath);
Expand All @@ -93,4 +99,11 @@ private static Provider resolveProvider(final Path manifestPath) {
throw new IllegalStateException(String.format("Unknown manifest file %s", manifestFile));
};
}

private static boolean isDockerfile(String filename) {
return filename.equals("Dockerfile")
|| filename.equals("Containerfile")
|| filename.startsWith("Dockerfile.")
|| filename.startsWith("Containerfile.");
}
}
53 changes: 53 additions & 0 deletions src/test/java/io/github/guacsec/trustifyda/image/ImageRefTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,59 @@ void test_imageRef() throws MalformedPackageURLException {
assertEquals(imageRef.hashCode(), imageRefPurl.hashCode());
}

private static final String TEST_DIGEST =
"sha256:333224a233db31852ac1085c6cd702016ab8aaf54cecde5c4bed5451d636adcf";

@Test
void test_docker_hub_bare_name_normalized_in_purl() throws MalformedPackageURLException {
var imageRef = new ImageRef("node:18@" + TEST_DIGEST, null);

var purl = imageRef.getPackageURL();

assertEquals("docker.io/node", purl.getQualifiers().get("repository_url"));
assertEquals("node", purl.getName());
}

@Test
void test_docker_hub_library_prefix_normalized_in_purl() throws MalformedPackageURLException {
var imageRef = new ImageRef("docker.io/library/node:18@" + TEST_DIGEST, null);

var purl = imageRef.getPackageURL();

assertEquals("docker.io/node", purl.getQualifiers().get("repository_url"));
assertEquals("node", purl.getName());
}

@Test
void test_docker_hub_both_forms_produce_same_purl() throws MalformedPackageURLException {
var bareRef = new ImageRef("node:18@" + TEST_DIGEST, null);
var libraryRef = new ImageRef("docker.io/library/node:18@" + TEST_DIGEST, null);

assertEquals(
bareRef.getPackageURL().getQualifiers().get("repository_url"),
libraryRef.getPackageURL().getQualifiers().get("repository_url"));
}

@Test
void test_non_docker_hub_registry_unchanged_in_purl() throws MalformedPackageURLException {
var imageRef =
new ImageRef("registry.access.redhat.com/ubi9/ubi-minimal:9.4@" + TEST_DIGEST, null);

var purl = imageRef.getPackageURL();

assertEquals(
"registry.access.redhat.com/ubi9/ubi-minimal", purl.getQualifiers().get("repository_url"));
}

@Test
void test_docker_hub_user_image_unchanged_in_purl() throws MalformedPackageURLException {
var imageRef = new ImageRef("docker.io/myuser/myimage:latest@" + TEST_DIGEST, null);

var purl = imageRef.getPackageURL();

assertEquals("docker.io/myuser/myimage", purl.getQualifiers().get("repository_url"));
}

@Test
void test_check_image_digest() throws IOException {
try (MockedStatic<Operations> mock = Mockito.mockStatic(Operations.class);
Expand Down
Loading
Loading