Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
19 changes: 14 additions & 5 deletions src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public final class ExhortApi implements Api {
public static final String S_API_V5_LICENSES = "%s/api/v5/licenses/%s";
public static final String S_API_V5_LICENSES_IDENTIFY = "%s/api/v5/licenses/identify";
private static final String TRUSTIFY_DA_LICENSE_CHECK = "TRUSTIFY_DA_LICENSE_CHECK";
private static final String TRUSTIFY_DA_RECOMMEND = "TRUSTIFY_DA_RECOMMEND";

private String endpoint;

Expand Down Expand Up @@ -361,13 +362,21 @@ public static boolean debugLoggingIsNeeded() {
return Environment.getBoolean("TRUSTIFY_DA_DEBUG", false);
}

private static URI buildAnalysisUri(String template, String endpoint) {
String base = String.format(template, endpoint);
if (!Environment.getBoolean(TRUSTIFY_DA_RECOMMEND, true)) {
return URI.create(base + "?recommend=false");
}
return URI.create(base);
}

@Override
public CompletableFuture<AnalysisReport> componentAnalysis(
final String manifest, final byte[] manifestContent) throws IOException {
String exClientTraceId = commonHookBeginning(false);
var manifestPath = Path.of(manifest);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint());
var content = provider.provideComponent();
commonHookAfterProviderCreatedSbomAndBeforeExhort();
return getAnalysisReportForComponent(uri, content, exClientTraceId);
Expand Down Expand Up @@ -411,7 +420,7 @@ public CompletableFuture<AnalysisReport> componentAnalysis(String manifestFile)
String exClientTraceId = commonHookBeginning(false);
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint());
var content = provider.provideComponent();
commonHookAfterProviderCreatedSbomAndBeforeExhort();
return getAnalysisReportForComponent(uri, content, exClientTraceId);
Expand Down Expand Up @@ -452,7 +461,7 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType
throws IOException {
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint());
var content = provider.provideStack();
commonHookAfterProviderCreatedSbomAndBeforeExhort();

Expand Down Expand Up @@ -539,7 +548,7 @@ <H, T> CompletableFuture<T> performBatchAnalysis(
final String analysisName)
throws IOException {
String exClientTraceId = commonHookBeginning(false);
var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, getEndpoint()));
var uri = buildAnalysisUri(S_API_V_5_BATCH_ANALYSIS, getEndpoint());
var sboms = sbomsGenerator.get();
var content =
new Provider.Content(
Expand Down Expand Up @@ -601,7 +610,7 @@ public CompletableFuture<ComponentAnalysisResult> componentAnalysisWithLicense(
String exClientTraceId = commonHookBeginning(false);
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint());
var content = provider.provideComponent();
String sbomJson = new String(content.buffer);
commonHookAfterProviderCreatedSbomAndBeforeExhort();
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