diff --git a/src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java b/src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java index 715b5bc3..c09d8217 100644 --- a/src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java +++ b/src/main/java/io/github/guacsec/trustifyda/image/ImageRef.java @@ -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 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()); } diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 47a801f7..7eb26cd7 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -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; @@ -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 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); @@ -411,7 +420,7 @@ public CompletableFuture 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); @@ -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(); @@ -539,7 +548,7 @@ CompletableFuture 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( @@ -601,7 +610,7 @@ public CompletableFuture 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(); diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java new file mode 100644 index 00000000..7d2f75cc --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java @@ -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 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; + } +} diff --git a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java index 3f6844fc..db60ec84 100644 --- a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java +++ b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java @@ -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; @@ -37,7 +38,8 @@ public enum Type { GOLANG("golang"), PYTHON("pypi"), GRADLE("gradle"), - CARGO("cargo"); + CARGO("cargo"), + DOCKERFILE("oci"); final String type; @@ -55,6 +57,7 @@ public String getExecutableShortName() { case PYTHON -> "python"; case GRADLE -> "gradle"; case CARGO -> "cargo"; + case DOCKERFILE -> "syft"; }; } @@ -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); @@ -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."); + } } diff --git a/src/test/java/io/github/guacsec/trustifyda/image/ImageRefTest.java b/src/test/java/io/github/guacsec/trustifyda/image/ImageRefTest.java index d3edb8c8..d119dc71 100644 --- a/src/test/java/io/github/guacsec/trustifyda/image/ImageRefTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/image/ImageRefTest.java @@ -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 mock = Mockito.mockStatic(Operations.class); diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java b/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java index 66976bd7..9819124b 100644 --- a/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java +++ b/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java @@ -896,6 +896,100 @@ void generateSbom_should_contain_metadata_component() throws IOException { Files.deleteIfExists(tmpFile); } + @Test + @SetSystemProperty(key = "TRUSTIFY_DA_RECOMMEND", value = "false") + @SetSystemProperty(key = "TRUST_DA_TOKEN", value = "trust-da-token") + @SetSystemProperty(key = "TRUST_DA_SOURCE", value = "trust-da-source") + void stackAnalysis_when_recommend_disabled_should_append_query_param() + throws IOException, ExecutionException, InterruptedException { + var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_pom_", ".xml"); + try (var is = + getResourceAsStreamDecision(this.getClass(), "tst_manifests/maven/empty/pom.xml")) { + Files.write(tmpFile, is.readAllBytes()); + } + + given(mockProvider.provideStack()) + .willReturn(new Provider.Content("fake-body-content".getBytes(), "fake-content-type")); + + ArgumentMatcher matchesRequest = + r -> + r.uri().toString().contains("?recommend=false") + && r.uri().toString().startsWith(exhortApiSut.getEndpoint() + "/api/v5/analysis") + && r.method().equals("POST"); + + var mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + AnalysisReport expectedAnalysis; + try (var is = + getResourceAsStreamDecision( + this.getClass(), "dummy_responses/maven/analysis-report.json")) { + expectedAnalysis = mapper.readValue(is, AnalysisReport.class); + } + + var mockHttpResponse = mock(HttpResponse.class); + given(mockHttpResponse.body()).willReturn(mapper.writeValueAsString(expectedAnalysis)); + given(mockHttpResponse.statusCode()).willReturn(200); + + try (var ecosystemTool = mockStatic(Ecosystem.class)) { + ecosystemTool.when(() -> Ecosystem.getProvider(tmpFile)).thenReturn(mockProvider); + + given(mockHttpClient.sendAsync(argThat(matchesRequest), any())) + .willReturn(CompletableFuture.completedFuture(mockHttpResponse)); + + var responseAnalysis = exhortApiSut.stackAnalysis(tmpFile.toString()); + then(responseAnalysis.get()).isEqualTo(expectedAnalysis); + } + Files.deleteIfExists(tmpFile); + } + + @Test + @ClearSystemProperty(key = "TRUSTIFY_DA_RECOMMEND") + @SetSystemProperty(key = "TRUST_DA_TOKEN", value = "trust-da-token") + @SetSystemProperty(key = "TRUST_DA_SOURCE", value = "trust-da-source") + void stackAnalysis_when_recommend_default_should_not_append_query_param() + throws IOException, ExecutionException, InterruptedException { + var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_pom_", ".xml"); + try (var is = + getResourceAsStreamDecision(this.getClass(), "tst_manifests/maven/empty/pom.xml")) { + Files.write(tmpFile, is.readAllBytes()); + } + + given(mockProvider.provideStack()) + .willReturn(new Provider.Content("fake-body-content".getBytes(), "fake-content-type")); + + ArgumentMatcher matchesRequest = + r -> + !r.uri().toString().contains("recommend") + && r.uri() + .equals( + URI.create( + String.format( + ExhortApi.S_API_V_5_ANALYSIS, exhortApiSut.getEndpoint()))) + && r.method().equals("POST"); + + var mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + AnalysisReport expectedAnalysis; + try (var is = + getResourceAsStreamDecision( + this.getClass(), "dummy_responses/maven/analysis-report.json")) { + expectedAnalysis = mapper.readValue(is, AnalysisReport.class); + } + + var mockHttpResponse = mock(HttpResponse.class); + given(mockHttpResponse.body()).willReturn(mapper.writeValueAsString(expectedAnalysis)); + given(mockHttpResponse.statusCode()).willReturn(200); + + try (var ecosystemTool = mockStatic(Ecosystem.class)) { + ecosystemTool.when(() -> Ecosystem.getProvider(tmpFile)).thenReturn(mockProvider); + + given(mockHttpClient.sendAsync(argThat(matchesRequest), any())) + .willReturn(CompletableFuture.completedFuture(mockHttpResponse)); + + var responseAnalysis = exhortApiSut.stackAnalysis(tmpFile.toString()); + then(responseAnalysis.get()).isEqualTo(expectedAnalysis); + } + Files.deleteIfExists(tmpFile); + } + @Test void generateSbom_should_not_make_http_calls() throws IOException { var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_pom_", ".xml"); diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Dockerfile_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Dockerfile_Provider_Test.java new file mode 100644 index 00000000..fdebec60 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/providers/Dockerfile_Provider_Test.java @@ -0,0 +1,199 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import io.github.guacsec.trustifyda.tools.Ecosystem; +import java.io.IOException; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** Tests for the DockerfileProvider and its integration with Ecosystem. */ +class Dockerfile_Provider_Test { + + private static final Path TEST_MANIFESTS = Path.of("src/test/resources/tst_manifests/dockerfile"); + + /** Verifies that Ecosystem.getProvider returns a DockerfileProvider for Dockerfile manifests. */ + @Test + void resolve_provider_returns_dockerfile_provider_for_dockerfile() { + var manifestPath = TEST_MANIFESTS.resolve("single_stage/Dockerfile"); + var provider = Ecosystem.getProvider(manifestPath); + + assertThat(provider).isInstanceOf(DockerfileProvider.class); + } + + /** + * Verifies that Ecosystem.getProvider returns a DockerfileProvider for Containerfile manifests. + */ + @Test + void resolve_provider_returns_dockerfile_provider_for_containerfile() { + var manifestPath = TEST_MANIFESTS.resolve("containerfile/Containerfile"); + var provider = Ecosystem.getProvider(manifestPath); + + assertThat(provider).isInstanceOf(DockerfileProvider.class); + } + + /** Verifies that a single-stage Dockerfile extracts the correct image reference. */ + @Test + void parse_from_extracts_image_from_single_stage_dockerfile() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("single_stage/Dockerfile"); + + String image = DockerfileProvider.parseLastFromImage(dockerfile); + + assertThat(image).isEqualTo("registry.access.redhat.com/ubi9/ubi-minimal:9.4"); + } + + /** Verifies that a multi-stage Dockerfile uses the last FROM instruction (final stage). */ + @Test + void parse_from_uses_last_from_in_multi_stage_dockerfile() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); + + String image = DockerfileProvider.parseLastFromImage(dockerfile); + + assertThat(image).isEqualTo("nginx:alpine"); + } + + /** Verifies that FROM with --platform flag extracts only the image reference. */ + @Test + void parse_from_strips_platform_flag() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("with_platform/Dockerfile"); + + String image = DockerfileProvider.parseLastFromImage(dockerfile); + + assertThat(image).isEqualTo("ubuntu:22.04"); + } + + /** Verifies that a Dockerfile with no FROM instruction throws an IOException. */ + @Test + void parse_from_throws_when_no_from_instruction() { + var dockerfile = TEST_MANIFESTS.resolve("no_from/Dockerfile"); + + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> DockerfileProvider.parseLastFromImage(dockerfile)) + .withMessageContaining("No FROM instruction found"); + } + + /** Verifies that FROM with multiple flags extracts only the image reference. */ + @Test + void parse_from_strips_multiple_flags() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("multiple_flags/Dockerfile"); + + String image = DockerfileProvider.parseLastFromImage(dockerfile); + + assertThat(image).isEqualTo("ubuntu:22.04"); + } + + /** Verifies that image references with digests are parsed correctly. */ + @Test + void parse_from_handles_image_with_digest() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("with_digest/Dockerfile"); + + String image = DockerfileProvider.parseLastFromImage(dockerfile); + + assertThat(image).isEqualTo("httpd@sha256:abc123"); + } + + /** Verifies that ARG-substituted FROM targets are rejected with a clear error. */ + @Test + void parse_from_throws_when_from_uses_arg_substitution() { + var dockerfile = TEST_MANIFESTS.resolve("arg_substitution/Dockerfile"); + + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> DockerfileProvider.parseLastFromImage(dockerfile)) + .withMessageContaining("ARG substitution"); + } + + /** Verifies that FROM line parsing is case-insensitive. */ + @Test + void parse_from_handles_lowercase_from_keyword() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("lowercase_from/Dockerfile"); + + String image = DockerfileProvider.parseLastFromImage(dockerfile); + + assertThat(image).isEqualTo("alpine:3.18"); + } + + /** Verifies that FROM scratch is rejected since there is no base image to analyze. */ + @Test + void parse_from_throws_when_from_scratch() { + var dockerfile = TEST_MANIFESTS.resolve("from_scratch/Dockerfile"); + + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> DockerfileProvider.parseLastFromImage(dockerfile)) + .withMessageContaining("FROM scratch"); + } + + /** Verifies that non-Dockerfile files with a Dockerfile-like prefix are not matched. */ + @Test + void resolve_provider_throws_for_non_dockerfile_prefix() { + // "Dockerfilesomething" without a dot separator should not be treated as a Dockerfile + var manifestPath = Path.of("Dockerfilesomething"); + + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> Ecosystem.getProvider(manifestPath)); + } + + /** Verifies that readLicenseFromManifest returns null for Dockerfiles. */ + @Test + void read_license_from_manifest_returns_null() { + var provider = new DockerfileProvider(TEST_MANIFESTS.resolve("single_stage/Dockerfile")); + + assertThat(provider.readLicenseFromManifest()).isNull(); + } + + /** Verifies that validateLockFile returns without error (no lock file required). */ + @Test + void validate_lock_file_does_not_throw() { + var provider = new DockerfileProvider(TEST_MANIFESTS.resolve("single_stage/Dockerfile")); + + // Should not throw — Dockerfiles have no lock file requirement + provider.validateLockFile(TEST_MANIFESTS.resolve("single_stage")); + } + + /** Verifies that both Dockerfile and Containerfile filenames resolve to DockerfileProvider. */ + @ParameterizedTest + @MethodSource("dockerfileManifests") + void resolve_provider_returns_dockerfile_provider_for_all_supported_names( + String description, Path manifestPath) { + var provider = Ecosystem.getProvider(manifestPath); + + assertThat(provider).isInstanceOf(DockerfileProvider.class); + assertThat(provider.ecosystem).isEqualTo(Ecosystem.Type.DOCKERFILE); + } + + /** Verifies that suffixed Dockerfile names (e.g. Dockerfile.dev) are supported. */ + @Test + void resolve_provider_returns_dockerfile_provider_for_suffixed_dockerfile() { + var manifestPath = TEST_MANIFESTS.resolve("suffixed/Dockerfile.dev"); + var provider = Ecosystem.getProvider(manifestPath); + + assertThat(provider).isInstanceOf(DockerfileProvider.class); + assertThat(provider.ecosystem).isEqualTo(Ecosystem.Type.DOCKERFILE); + } + + static Stream dockerfileManifests() { + return Stream.of( + Arguments.of("Dockerfile", TEST_MANIFESTS.resolve("single_stage/Dockerfile")), + Arguments.of("Containerfile", TEST_MANIFESTS.resolve("containerfile/Containerfile"))); + } +} diff --git a/src/test/resources/tst_manifests/dockerfile/arg_substitution/Dockerfile b/src/test/resources/tst_manifests/dockerfile/arg_substitution/Dockerfile new file mode 100644 index 00000000..6695a48b --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/arg_substitution/Dockerfile @@ -0,0 +1,4 @@ +ARG BASE_IMAGE=ubuntu:22.04 +FROM ${BASE_IMAGE} + +RUN echo hello diff --git a/src/test/resources/tst_manifests/dockerfile/containerfile/Containerfile b/src/test/resources/tst_manifests/dockerfile/containerfile/Containerfile new file mode 100644 index 00000000..51590d24 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/containerfile/Containerfile @@ -0,0 +1,3 @@ +FROM registry.access.redhat.com/ubi9/ubi:9.4 + +RUN dnf install -y python3 && dnf clean all diff --git a/src/test/resources/tst_manifests/dockerfile/from_scratch/Dockerfile b/src/test/resources/tst_manifests/dockerfile/from_scratch/Dockerfile new file mode 100644 index 00000000..531892f2 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/from_scratch/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch + +COPY binary / +ENTRYPOINT ["/binary"] diff --git a/src/test/resources/tst_manifests/dockerfile/lowercase_from/Dockerfile b/src/test/resources/tst_manifests/dockerfile/lowercase_from/Dockerfile new file mode 100644 index 00000000..98745957 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/lowercase_from/Dockerfile @@ -0,0 +1,3 @@ +from alpine:3.18 + +RUN apk add curl diff --git a/src/test/resources/tst_manifests/dockerfile/multi_stage/Dockerfile b/src/test/resources/tst_manifests/dockerfile/multi_stage/Dockerfile new file mode 100644 index 00000000..5912990e --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/multi_stage/Dockerfile @@ -0,0 +1,9 @@ +FROM node:18 AS builder + +WORKDIR /app +COPY . . +RUN npm ci && npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html diff --git a/src/test/resources/tst_manifests/dockerfile/multiple_flags/Dockerfile b/src/test/resources/tst_manifests/dockerfile/multiple_flags/Dockerfile new file mode 100644 index 00000000..88a5103d --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/multiple_flags/Dockerfile @@ -0,0 +1,3 @@ +FROM --platform=linux/amd64 --some-flag=value ubuntu:22.04 AS base + +RUN apt-get update diff --git a/src/test/resources/tst_manifests/dockerfile/no_from/Dockerfile b/src/test/resources/tst_manifests/dockerfile/no_from/Dockerfile new file mode 100644 index 00000000..59beec64 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/no_from/Dockerfile @@ -0,0 +1,2 @@ +# This is a comment-only Dockerfile with no FROM +# ARG BASE_IMAGE=ubuntu:22.04 diff --git a/src/test/resources/tst_manifests/dockerfile/single_stage/Dockerfile b/src/test/resources/tst_manifests/dockerfile/single_stage/Dockerfile new file mode 100644 index 00000000..ed9c129b --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/single_stage/Dockerfile @@ -0,0 +1,6 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal:9.4 + +RUN microdnf install -y java-17-openjdk-headless && microdnf clean all + +COPY target/app.jar /app.jar +CMD ["java", "-jar", "/app.jar"] diff --git a/src/test/resources/tst_manifests/dockerfile/suffixed/Dockerfile.dev b/src/test/resources/tst_manifests/dockerfile/suffixed/Dockerfile.dev new file mode 100644 index 00000000..48316d4a --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/suffixed/Dockerfile.dev @@ -0,0 +1,3 @@ +FROM node:18-alpine + +RUN npm install diff --git a/src/test/resources/tst_manifests/dockerfile/with_digest/Dockerfile b/src/test/resources/tst_manifests/dockerfile/with_digest/Dockerfile new file mode 100644 index 00000000..9a6a3951 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/with_digest/Dockerfile @@ -0,0 +1,3 @@ +FROM httpd@sha256:abc123 + +COPY ./public-html /usr/local/apache2/htdocs/ diff --git a/src/test/resources/tst_manifests/dockerfile/with_platform/Dockerfile b/src/test/resources/tst_manifests/dockerfile/with_platform/Dockerfile new file mode 100644 index 00000000..d84686c2 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/with_platform/Dockerfile @@ -0,0 +1,3 @@ +FROM --platform=linux/amd64 ubuntu:22.04 + +RUN apt-get update && apt-get install -y curl