From bf0257f5c074abf341b099dafe87aa21799a0563 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Wed, 1 Jul 2026 14:09:08 +0300 Subject: [PATCH 1/5] 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 --- .../guacsec/trustifyda/image/ImageRef.java | 15 ++ .../providers/DockerfileProvider.java | 116 ++++++++++ .../guacsec/trustifyda/tools/Ecosystem.java | 15 +- .../trustifyda/image/ImageRefTest.java | 53 +++++ .../providers/Dockerfile_Provider_Test.java | 199 ++++++++++++++++++ .../dockerfile/arg_substitution/Dockerfile | 4 + .../dockerfile/containerfile/Containerfile | 3 + .../dockerfile/from_scratch/Dockerfile | 4 + .../dockerfile/lowercase_from/Dockerfile | 3 + .../dockerfile/multi_stage/Dockerfile | 9 + .../dockerfile/multiple_flags/Dockerfile | 3 + .../dockerfile/no_from/Dockerfile | 2 + .../dockerfile/single_stage/Dockerfile | 6 + .../dockerfile/suffixed/Dockerfile.dev | 3 + .../dockerfile/with_digest/Dockerfile | 3 + .../dockerfile/with_platform/Dockerfile | 3 + 16 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java create mode 100644 src/test/java/io/github/guacsec/trustifyda/providers/Dockerfile_Provider_Test.java create mode 100644 src/test/resources/tst_manifests/dockerfile/arg_substitution/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/containerfile/Containerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/from_scratch/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/lowercase_from/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/multi_stage/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/multiple_flags/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/no_from/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/single_stage/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/suffixed/Dockerfile.dev create mode 100644 src/test/resources/tst_manifests/dockerfile/with_digest/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/with_platform/Dockerfile 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/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/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 From 63309e25640277a7922e38a1225bbf05996fe4cf Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Thu, 2 Jul 2026 12:31:00 +0300 Subject: [PATCH 2/5] feat(api): add TRUSTIFY_DA_RECOMMEND env var to disable recommendations Aligns with the JavaScript client by allowing users to set TRUSTIFY_DA_RECOMMEND=false to append ?recommend=false to analysis URLs, disabling Trusted Content recommendations in responses. Co-Authored-By: Claude Opus 4.6 --- .../guacsec/trustifyda/impl/ExhortApi.java | 19 +++- .../trustifyda/impl/Exhort_Api_Test.java | 94 +++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) 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/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"); From 3b43c23b7730e25a306743e002ed255bcc6e2d49 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Tue, 7 Jul 2026 11:17:02 +0300 Subject: [PATCH 3/5] fix(dockerfile): analyze all FROM lines via batch analysis Change the DockerfileProvider from analyzing only the last FROM line to analyzing all FROM lines in multi-stage Dockerfiles. Each image gets its own SBOM via syft, and results are sent as a batch request to /api/v5/batch-analysis. - Add batch flag to Provider.Content to route between single and batch endpoints - Resolve ARG substitutions (both ${VAR} and $VAR syntax) with default values - Skip FROM scratch and unresolvable ARG references - Route batch content through ExhortApi to the batch-analysis endpoint Fixes: TC-5071 Co-Authored-By: Claude Opus 4.6 --- .../github/guacsec/trustifyda/Provider.java | 6 + .../guacsec/trustifyda/impl/ExhortApi.java | 136 ++++-- .../providers/DockerfileProvider.java | 134 ++++-- .../providers/Dockerfile_Provider_Test.java | 423 +++++++++++++----- .../dockerfile/all_invalid/Dockerfile | 7 + .../dockerfile/arg_bare_var/Dockerfile | 4 + .../dockerfile/arg_no_default/Dockerfile | 4 + .../dockerfile/multi_stage_mixed/Dockerfile | 18 + 8 files changed, 548 insertions(+), 184 deletions(-) create mode 100644 src/test/resources/tst_manifests/dockerfile/all_invalid/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/arg_bare_var/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/arg_no_default/Dockerfile create mode 100644 src/test/resources/tst_manifests/dockerfile/multi_stage_mixed/Dockerfile diff --git a/src/main/java/io/github/guacsec/trustifyda/Provider.java b/src/main/java/io/github/guacsec/trustifyda/Provider.java index 95a3d686..f635eedc 100644 --- a/src/main/java/io/github/guacsec/trustifyda/Provider.java +++ b/src/main/java/io/github/guacsec/trustifyda/Provider.java @@ -37,10 +37,16 @@ public abstract class Provider { public static class Content { public final byte[] buffer; public final String type; + public final boolean batch; public Content(byte[] buffer, String type) { + this(buffer, type, false); + } + + public Content(byte[] buffer, String type, boolean batch) { this.buffer = buffer; this.type = type; + this.batch = batch; } } 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 7eb26cd7..fc3ac316 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -297,10 +297,36 @@ public CompletableFuture stackAnalysisHtml(final String manifestFile) th public CompletableFuture stackAnalysis(final String manifestFile) throws IOException { String exClientTraceId = commonHookBeginning(false); + var content = resolveStackContent(manifestFile); + var uri = resolveAnalysisUri(content); + var request = buildRequest(content, uri, MediaType.APPLICATION_JSON, "Stack Analysis"); + if (content.batch) { + return this.client + .sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply( + response -> { + RequestManager.getInstance().addClientTraceIdToRequest(exClientTraceId); + if (debugLoggingIsNeeded()) { + logExhortRequestId(response); + } + Map reports = getBatchStackAnalysisReports(response); + commonHookAfterExhortResponse(); + return reports.isEmpty() + ? new AnalysisReport() + : reports.values().iterator().next(); + }) + .exceptionally( + exception -> { + LOG.severe( + String.format( + "failed to invoke stackAnalysis (batch) for getting the json report," + + " received message= %s ", + exception.getMessage())); + return new AnalysisReport(); + }); + } return this.client - .sendAsync( - this.buildStackRequest(manifestFile, MediaType.APPLICATION_JSON), - HttpResponse.BodyHandlers.ofString()) + .sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply( response -> getAnalysisReportFromResponse(response, "StackAnalysis", "json", exClientTraceId)) @@ -376,9 +402,12 @@ public CompletableFuture componentAnalysis( String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifest); var provider = Ecosystem.getProvider(manifestPath); - var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint()); var content = provider.provideComponent(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); + var uri = resolveAnalysisUri(content); + if (content.batch) { + return getBatchAnalysisReportForComponent(uri, content, exClientTraceId); + } return getAnalysisReportForComponent(uri, content, exClientTraceId); } @@ -420,9 +449,12 @@ public CompletableFuture componentAnalysis(String manifestFile) String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint()); var content = provider.provideComponent(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); + var uri = resolveAnalysisUri(content); + if (content.batch) { + return getBatchAnalysisReportForComponent(uri, content, exClientTraceId); + } return getAnalysisReportForComponent(uri, content, exClientTraceId); } @@ -449,8 +481,48 @@ private CompletableFuture getAnalysisReportForComponent( }); } + /** Resolves the analysis URI based on whether the content is batch or single. */ + private URI resolveAnalysisUri(Provider.Content content) { + if (content.batch) { + return buildAnalysisUri(S_API_V_5_BATCH_ANALYSIS, getEndpoint()); + } + return buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint()); + } + /** - * Build an HTTP request wrapper for sending to the Backend API for Stack Analysis only. + * Sends batch content to the batch-analysis endpoint and returns the first analysis report from + * the batch response map. + */ + private CompletableFuture getBatchAnalysisReportForComponent( + URI uri, Provider.Content content, String exClientTraceId) { + return this.client + .sendAsync( + this.buildRequest(content, uri, MediaType.APPLICATION_JSON, "Batch Component Analysis"), + HttpResponse.BodyHandlers.ofString()) + .thenApply( + response -> { + RequestManager.getInstance().addClientTraceIdToRequest(exClientTraceId); + if (debugLoggingIsNeeded()) { + logExhortRequestId(response); + } + Map reports = getBatchStackAnalysisReports(response); + commonHookAfterExhortResponse(); + return reports.isEmpty() ? new AnalysisReport() : reports.values().iterator().next(); + }) + .exceptionally( + exception -> { + LOG.severe( + String.format( + "failed to invoke Batch Component Analysis for getting the json report," + + " received message= %s ", + exception.getMessage())); + return new AnalysisReport(); + }); + } + + /** + * Build an HTTP request wrapper for sending to the Backend API for Stack Analysis only. Uses the + * batch-analysis endpoint when the provider returns batch content. * * @param manifestFile the path for the manifest file * @param acceptType the type of requested content @@ -459,13 +531,21 @@ private CompletableFuture getAnalysisReportForComponent( */ private HttpRequest buildStackRequest(final String manifestFile, final MediaType acceptType) throws IOException { + var content = resolveStackContent(manifestFile); + var uri = resolveAnalysisUri(content); + return buildRequest(content, uri, acceptType, "Stack Analysis"); + } + + /** + * Resolves the provider for the given manifest file and produces the stack content. Also tracks + * timing via {@link #commonHookAfterProviderCreatedSbomAndBeforeExhort()}. + */ + private Provider.Content resolveStackContent(String manifestFile) throws IOException { var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint()); var content = provider.provideStack(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); - - return buildRequest(content, uri, acceptType, "Stack Analysis"); + return content; } @Override @@ -610,27 +690,29 @@ public CompletableFuture componentAnalysisWithLicense( String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = buildAnalysisUri(S_API_V_5_ANALYSIS, getEndpoint()); var content = provider.provideComponent(); String sbomJson = new String(content.buffer); commonHookAfterProviderCreatedSbomAndBeforeExhort(); - return getAnalysisReportForComponent(uri, content, exClientTraceId) - .thenCompose( - report -> { - if (!isLicenseCheckEnabled()) { - return CompletableFuture.completedFuture(new ComponentAnalysisResult(report, null)); - } - return LicenseCheck.runLicenseCheck(this, provider, manifestPath, sbomJson, report) - .thenApply(summary -> new ComponentAnalysisResult(report, summary)) - .exceptionally( - ex -> { - LOG.warning( - String.format( - "License check failed, continuing without it: %s", - ex.getMessage())); - return new ComponentAnalysisResult(report, null); - }); - }); + var uri = resolveAnalysisUri(content); + CompletableFuture reportFuture = + content.batch + ? getBatchAnalysisReportForComponent(uri, content, exClientTraceId) + : getAnalysisReportForComponent(uri, content, exClientTraceId); + return reportFuture.thenCompose( + report -> { + if (!isLicenseCheckEnabled() || content.batch) { + return CompletableFuture.completedFuture(new ComponentAnalysisResult(report, null)); + } + return LicenseCheck.runLicenseCheck(this, provider, manifestPath, sbomJson, report) + .thenApply(summary -> new ComponentAnalysisResult(report, summary)) + .exceptionally( + ex -> { + LOG.warning( + String.format( + "License check failed, continuing without it: %s", ex.getMessage())); + return new ComponentAnalysisResult(report, null); + }); + }); } /** diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java index 7d2f75cc..1f4134f6 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java @@ -16,38 +16,55 @@ */ package io.github.guacsec.trustifyda.providers; +import com.fasterxml.jackson.databind.JsonNode; 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.logging.LoggersFactory; import io.github.guacsec.trustifyda.tools.Ecosystem.Type; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import java.util.regex.Matcher; 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. + * Provider for Dockerfile and Containerfile manifests. Parses all FROM instructions to extract base + * image references, then uses syft to generate CycloneDX SBOMs for batch analysis. */ public final class DockerfileProvider extends Provider { + private static final Logger LOG = LoggersFactory.getLogger(DockerfileProvider.class.getName()); + private static final Pattern FROM_LINE_PATTERN = Pattern.compile("^FROM\\s+", Pattern.CASE_INSENSITIVE); + private static final Pattern ARG_LINE_PATTERN = + Pattern.compile("^ARG\\s+([A-Za-z_][A-Za-z0-9_]*)=(\\S+)", Pattern.CASE_INSENSITIVE); + + private static final Pattern VAR_REF_PATTERN = + Pattern.compile("\\$\\{([^}]+)}|\\$([A-Za-z_][A-Za-z0-9_]*)"); + public DockerfileProvider(Path manifest) { super(Type.DOCKERFILE, manifest); } @Override public Content provideStack() throws IOException { - return generateSbomContent(); + return generateBatchSbomContent(); } @Override public Content provideComponent() throws IOException { - return generateSbomContent(); + return generateBatchSbomContent(); } @Override @@ -56,61 +73,102 @@ public String readLicenseFromManifest() { } /** - * Parses the manifest file to find the last FROM instruction and generates a CycloneDX SBOM for - * the referenced image. + * Parses the manifest file to find all FROM instructions and generates a CycloneDX SBOM for each + * image. Returns batch content as a JSON object mapping purls to SBOM objects. */ - 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); + private Content generateBatchSbomContent() throws IOException { + List imageReferences = parseAllFromImages(manifestPath); + Map purlToSbom = new LinkedHashMap<>(); + for (String imageReference : imageReferences) { + try { + ImageRef imageRef = ImageUtils.parseImageRef(imageReference); + JsonNode sbomNode = ImageUtils.generateImageSBOM(imageRef); + String purl = imageRef.getPackageURL().toString(); + purlToSbom.put(purl, sbomNode); + } catch (Exception e) { + LOG.warning( + String.format("Skipping image %s due to error: %s", imageReference, e.getMessage())); + } } + if (purlToSbom.isEmpty()) { + throw new IOException("No analyzable FROM images found in " + manifestPath); + } + byte[] batchBytes = + objectMapper.writeValueAsString(purlToSbom).getBytes(StandardCharsets.UTF_8); + return new Content(batchBytes, Api.CYCLONEDX_MEDIA_TYPE, true); } /** - * 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. + * Parses a Dockerfile/Containerfile and extracts image references from all FROM instructions. + * Resolves ARG substitutions using default values when available. Skips FROM lines with + * unresolvable ARG references (no default value) or that reference {@code scratch}. * * @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 + * @return list of image reference strings from all valid FROM instructions + * @throws IOException if the file cannot be read or contains no analyzable FROM instruction */ - static String parseLastFromImage(Path dockerfile) throws IOException { + static List parseAllFromImages(Path dockerfile) throws IOException { List lines = Files.readAllLines(dockerfile); - String lastImage = null; + Map argDefaults = new HashMap<>(); + List images = new ArrayList<>(); 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()); + var argMatcher = ARG_LINE_PATTERN.matcher(trimmed); + if (argMatcher.find()) { + argDefaults.put(argMatcher.group(1), argMatcher.group(2)); + continue; + } + var fromMatcher = FROM_LINE_PATTERN.matcher(trimmed); + if (fromMatcher.find()) { + String remainder = trimmed.substring(fromMatcher.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]; + String image = tokens[i]; + if (image.contains("$")) { + image = resolveArgSubstitutions(image, argDefaults); + if (image == null) { + LOG.info( + String.format( + "Skipping FROM line with unresolvable ARG in %s: %s", dockerfile, tokens[i])); + continue; + } + } + if ("scratch".equals(image)) { + LOG.info(String.format("Skipping FROM scratch in %s", dockerfile)); + continue; + } + images.add(image); } } } - 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 (images.isEmpty()) { + throw new IOException("No analyzable FROM instruction found in " + dockerfile); } - if ("scratch".equals(lastImage)) { - throw new IOException( - "Dockerfile uses FROM scratch — no base image to analyze: " + dockerfile); + return images; + } + + /** + * Resolves {@code ${VAR}} and {@code $VAR} references in an image string using collected ARG + * defaults. + * + * @return the resolved image string, or {@code null} if any variable has no default value + */ + private static String resolveArgSubstitutions(String image, Map argDefaults) { + Matcher varMatcher = VAR_REF_PATTERN.matcher(image); + StringBuilder sb = new StringBuilder(); + while (varMatcher.find()) { + String varName = varMatcher.group(1) != null ? varMatcher.group(1) : varMatcher.group(2); + String defaultValue = argDefaults.get(varName); + if (defaultValue == null) { + return null; + } + varMatcher.appendReplacement(sb, Matcher.quoteReplacement(defaultValue)); } - return lastImage; + varMatcher.appendTail(sb); + return sb.toString(); } } 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 index fdebec60..de23db98 100644 --- 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 @@ -19,176 +19,361 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.packageurl.PackageURL; +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; import java.io.IOException; import java.nio.file.Path; +import java.util.List; import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; 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; +import org.mockito.MockedStatic; +import org.mockito.Mockito; -/** Tests for the DockerfileProvider and its integration with Ecosystem. */ +/** Tests for the DockerfileProvider batch multi-FROM behavior and Ecosystem integration. */ class Dockerfile_Provider_Test { private static final Path TEST_MANIFESTS = Path.of("src/test/resources/tst_manifests/dockerfile"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Nested + class EcosystemIntegration { + + /** + * 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 non-Dockerfile files with a Dockerfile-like prefix are not matched. */ + @Test + void resolve_provider_throws_for_non_dockerfile_prefix() { + var manifestPath = Path.of("Dockerfilesomething"); + + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> Ecosystem.getProvider(manifestPath)); + } + + /** Verifies that both Dockerfile and Containerfile filenames resolve to DockerfileProvider. */ + @ParameterizedTest + @MethodSource( + "io.github.guacsec.trustifyda.providers.Dockerfile_Provider_Test#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); + } + } - /** 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); + @Nested + class ParseAllFromImages { - assertThat(provider).isInstanceOf(DockerfileProvider.class); - } + /** Verifies that a single-stage Dockerfile returns a single-element list. */ + @Test + void returns_single_element_list_for_single_from_dockerfile() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("single_stage/Dockerfile"); - /** - * 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); + List images = DockerfileProvider.parseAllFromImages(dockerfile); - assertThat(provider).isInstanceOf(DockerfileProvider.class); - } + assertThat(images) + .hasSize(1) + .containsExactly("registry.access.redhat.com/ubi9/ubi-minimal:9.4"); + } - /** 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"); + /** Verifies that a multi-stage Dockerfile returns all FROM image references. */ + @Test + void returns_all_images_from_multi_stage_dockerfile() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); - String image = DockerfileProvider.parseLastFromImage(dockerfile); + List images = DockerfileProvider.parseAllFromImages(dockerfile); - assertThat(image).isEqualTo("registry.access.redhat.com/ubi9/ubi-minimal:9.4"); - } + assertThat(images).hasSize(2).containsExactly("node:18", "nginx:alpine"); + } - /** 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"); + /** Verifies that FROM with --platform flag extracts only the image reference. */ + @Test + void strips_platform_flag() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("with_platform/Dockerfile"); - String image = DockerfileProvider.parseLastFromImage(dockerfile); + List images = DockerfileProvider.parseAllFromImages(dockerfile); - assertThat(image).isEqualTo("nginx:alpine"); - } + assertThat(images).hasSize(1).containsExactly("ubuntu:22.04"); + } - /** 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"); + /** Verifies that FROM with multiple flags extracts only the image reference. */ + @Test + void strips_multiple_flags() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("multiple_flags/Dockerfile"); - String image = DockerfileProvider.parseLastFromImage(dockerfile); + List images = DockerfileProvider.parseAllFromImages(dockerfile); - assertThat(image).isEqualTo("ubuntu:22.04"); - } + assertThat(images).hasSize(1).containsExactly("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"); + /** Verifies that image references with digests are parsed correctly. */ + @Test + void handles_image_with_digest() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("with_digest/Dockerfile"); - assertThatExceptionOfType(IOException.class) - .isThrownBy(() -> DockerfileProvider.parseLastFromImage(dockerfile)) - .withMessageContaining("No FROM instruction found"); - } + List images = DockerfileProvider.parseAllFromImages(dockerfile); - /** 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"); + assertThat(images).hasSize(1).containsExactly("httpd@sha256:abc123"); + } - String image = DockerfileProvider.parseLastFromImage(dockerfile); + /** Verifies that FROM line parsing is case-insensitive. */ + @Test + void handles_lowercase_from_keyword() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("lowercase_from/Dockerfile"); - assertThat(image).isEqualTo("ubuntu:22.04"); - } + List images = DockerfileProvider.parseAllFromImages(dockerfile); - /** 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"); + assertThat(images).hasSize(1).containsExactly("alpine:3.18"); + } - String image = DockerfileProvider.parseLastFromImage(dockerfile); + /** Verifies that ARG with default value is resolved in FROM line using ${VAR} syntax. */ + @Test + void resolves_arg_substitution_with_braced_syntax() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("arg_substitution/Dockerfile"); - assertThat(image).isEqualTo("httpd@sha256:abc123"); - } + List images = DockerfileProvider.parseAllFromImages(dockerfile); - /** 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"); + assertThat(images).hasSize(1).containsExactly("ubuntu:22.04"); + } - assertThatExceptionOfType(IOException.class) - .isThrownBy(() -> DockerfileProvider.parseLastFromImage(dockerfile)) - .withMessageContaining("ARG substitution"); - } + /** Verifies that ARG with default value is resolved in FROM line using $VAR syntax. */ + @Test + void resolves_arg_substitution_with_bare_syntax() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("arg_bare_var/Dockerfile"); - /** 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"); + List images = DockerfileProvider.parseAllFromImages(dockerfile); - String image = DockerfileProvider.parseLastFromImage(dockerfile); + assertThat(images).hasSize(1).containsExactly("ubuntu:22.04"); + } - assertThat(image).isEqualTo("alpine:3.18"); - } + /** Verifies that ARG without default value causes the FROM line to be skipped. */ + @Test + void skips_arg_substitution_without_default_value() { + var dockerfile = TEST_MANIFESTS.resolve("arg_no_default/Dockerfile"); - /** 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.parseAllFromImages(dockerfile)) + .withMessageContaining("No analyzable FROM instruction found"); + } - assertThatExceptionOfType(IOException.class) - .isThrownBy(() -> DockerfileProvider.parseLastFromImage(dockerfile)) - .withMessageContaining("FROM scratch"); - } + /** Verifies that multi-stage mixed Dockerfile resolves ARGs and skips scratch. */ + @Test + void resolves_args_and_skips_scratch_in_mixed_dockerfile() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage_mixed/Dockerfile"); - /** 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"); + List images = DockerfileProvider.parseAllFromImages(dockerfile); - assertThatExceptionOfType(IllegalStateException.class) - .isThrownBy(() -> Ecosystem.getProvider(manifestPath)); - } + assertThat(images).hasSize(3).containsExactly("ubuntu:22.04", "node:18", "nginx:alpine"); + } - /** 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")); + /** Verifies that FROM scratch lines are skipped and remaining images are returned. */ + @Test + void skips_scratch_and_returns_remaining_images() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage_mixed/Dockerfile"); - assertThat(provider.readLicenseFromManifest()).isNull(); - } + List images = DockerfileProvider.parseAllFromImages(dockerfile); - /** 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")); + assertThat(images).doesNotContain("scratch"); + } - // Should not throw — Dockerfiles have no lock file requirement - provider.validateLockFile(TEST_MANIFESTS.resolve("single_stage")); - } + /** Verifies that a Dockerfile with no analyzable FROM instructions throws IOException. */ + @Test + void throws_when_no_analyzable_from_instruction() { + var dockerfile = TEST_MANIFESTS.resolve("no_from/Dockerfile"); + + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> DockerfileProvider.parseAllFromImages(dockerfile)) + .withMessageContaining("No analyzable FROM instruction found"); + } + + /** Verifies that a Dockerfile where all FROM lines are invalid throws IOException. */ + @Test + void throws_when_all_from_lines_are_invalid() { + var dockerfile = TEST_MANIFESTS.resolve("all_invalid/Dockerfile"); + + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> DockerfileProvider.parseAllFromImages(dockerfile)) + .withMessageContaining("No analyzable FROM instruction found"); + } - /** 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); + /** Verifies that a Containerfile returns the correct image reference. */ + @Test + void parses_containerfile() throws IOException { + var containerfile = TEST_MANIFESTS.resolve("containerfile/Containerfile"); - assertThat(provider).isInstanceOf(DockerfileProvider.class); - assertThat(provider.ecosystem).isEqualTo(Ecosystem.Type.DOCKERFILE); + List images = DockerfileProvider.parseAllFromImages(containerfile); + + assertThat(images).hasSize(1).containsExactly("registry.access.redhat.com/ubi9/ubi:9.4"); + } + } + + @Nested + class BatchContent { + + /** Verifies that provideStack returns batch content with SBOMs for all FROM images. */ + @Test + void provide_stack_returns_batch_content_for_multi_stage_dockerfile() throws Exception { + // Given a multi-stage Dockerfile with two valid FROM images + var manifestPath = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); + var provider = new DockerfileProvider(manifestPath); + + JsonNode nodeSbom = MAPPER.createObjectNode().put("bomFormat", "CycloneDX"); + ImageRef nodeRef = Mockito.mock(ImageRef.class); + PackageURL nodePurl = new PackageURL("pkg:oci/node@18"); + Mockito.when(nodeRef.getPackageURL()).thenReturn(nodePurl); + + JsonNode nginxSbom = MAPPER.createObjectNode().put("bomFormat", "CycloneDX"); + ImageRef nginxRef = Mockito.mock(ImageRef.class); + PackageURL nginxPurl = new PackageURL("pkg:oci/nginx@alpine"); + Mockito.when(nginxRef.getPackageURL()).thenReturn(nginxPurl); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock.when(() -> ImageUtils.parseImageRef("node:18")).thenReturn(nodeRef); + imageUtilsMock.when(() -> ImageUtils.parseImageRef("nginx:alpine")).thenReturn(nginxRef); + imageUtilsMock.when(() -> ImageUtils.generateImageSBOM(nodeRef)).thenReturn(nodeSbom); + imageUtilsMock.when(() -> ImageUtils.generateImageSBOM(nginxRef)).thenReturn(nginxSbom); + + // When + Provider.Content content = provider.provideStack(); + + // Then + assertThat(content.batch).isTrue(); + assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE); + + JsonNode batchJson = MAPPER.readTree(content.buffer); + assertThat(batchJson.isObject()).isTrue(); + assertThat(batchJson.size()).isEqualTo(2); + assertThat(batchJson.has(nodePurl.toString())).isTrue(); + assertThat(batchJson.has(nginxPurl.toString())).isTrue(); + } + } + + /** Verifies that provideComponent returns batch content with correct purl keys. */ + @Test + void provide_component_returns_batch_content_with_correct_purl_keys() throws Exception { + // Given a single-stage Dockerfile + var manifestPath = TEST_MANIFESTS.resolve("single_stage/Dockerfile"); + var provider = new DockerfileProvider(manifestPath); + + JsonNode sbom = MAPPER.createObjectNode().put("bomFormat", "CycloneDX"); + ImageRef imageRef = Mockito.mock(ImageRef.class); + PackageURL purl = + new PackageURL("pkg:oci/ubi-minimal@9.4?repository_url=registry.access.redhat.com"); + Mockito.when(imageRef.getPackageURL()).thenReturn(purl); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock + .when(() -> ImageUtils.parseImageRef("registry.access.redhat.com/ubi9/ubi-minimal:9.4")) + .thenReturn(imageRef); + imageUtilsMock.when(() -> ImageUtils.generateImageSBOM(imageRef)).thenReturn(sbom); + + // When + Provider.Content content = provider.provideComponent(); + + // Then batch with one entry for single-FROM Dockerfile + assertThat(content.batch).isTrue(); + assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE); + + JsonNode batchJson = MAPPER.readTree(content.buffer); + assertThat(batchJson.isObject()).isTrue(); + assertThat(batchJson.size()).isEqualTo(1); + assertThat(batchJson.has(purl.toString())).isTrue(); + assertThat(batchJson.get(purl.toString()).get("bomFormat").asText()).isEqualTo("CycloneDX"); + } + } + + /** Verifies that batch content skips images that fail SBOM generation and includes the rest. */ + @Test + void provide_stack_skips_failing_images_in_batch() throws Exception { + // Given a multi-stage Dockerfile where one image fails SBOM generation + var manifestPath = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); + var provider = new DockerfileProvider(manifestPath); + + ImageRef nodeRef = Mockito.mock(ImageRef.class); + JsonNode nginxSbom = MAPPER.createObjectNode().put("bomFormat", "CycloneDX"); + ImageRef nginxRef = Mockito.mock(ImageRef.class); + PackageURL nginxPurl = new PackageURL("pkg:oci/nginx@alpine"); + Mockito.when(nginxRef.getPackageURL()).thenReturn(nginxPurl); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock.when(() -> ImageUtils.parseImageRef("node:18")).thenReturn(nodeRef); + imageUtilsMock + .when(() -> ImageUtils.generateImageSBOM(nodeRef)) + .thenThrow(new IOException("syft not available")); + imageUtilsMock.when(() -> ImageUtils.parseImageRef("nginx:alpine")).thenReturn(nginxRef); + imageUtilsMock.when(() -> ImageUtils.generateImageSBOM(nginxRef)).thenReturn(nginxSbom); + + // When + Provider.Content content = provider.provideStack(); + + // Then only the successful image is included + assertThat(content.batch).isTrue(); + JsonNode batchJson = MAPPER.readTree(content.buffer); + assertThat(batchJson.size()).isEqualTo(1); + assertThat(batchJson.has(nginxPurl.toString())).isTrue(); + } + } } - /** 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); + @Nested + class ProviderProperties { + + /** 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")); - assertThat(provider).isInstanceOf(DockerfileProvider.class); - assertThat(provider.ecosystem).isEqualTo(Ecosystem.Type.DOCKERFILE); + provider.validateLockFile(TEST_MANIFESTS.resolve("single_stage")); + } } static Stream dockerfileManifests() { diff --git a/src/test/resources/tst_manifests/dockerfile/all_invalid/Dockerfile b/src/test/resources/tst_manifests/dockerfile/all_invalid/Dockerfile new file mode 100644 index 00000000..a67a695b --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/all_invalid/Dockerfile @@ -0,0 +1,7 @@ +ARG BASE +FROM ${BASE} + +FROM scratch + +COPY binary / +ENTRYPOINT ["/binary"] diff --git a/src/test/resources/tst_manifests/dockerfile/arg_bare_var/Dockerfile b/src/test/resources/tst_manifests/dockerfile/arg_bare_var/Dockerfile new file mode 100644 index 00000000..85911b49 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/arg_bare_var/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/arg_no_default/Dockerfile b/src/test/resources/tst_manifests/dockerfile/arg_no_default/Dockerfile new file mode 100644 index 00000000..0a26e3bf --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/arg_no_default/Dockerfile @@ -0,0 +1,4 @@ +ARG BASE +FROM ${BASE} + +RUN echo hello diff --git a/src/test/resources/tst_manifests/dockerfile/multi_stage_mixed/Dockerfile b/src/test/resources/tst_manifests/dockerfile/multi_stage_mixed/Dockerfile new file mode 100644 index 00000000..17f9e990 --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/multi_stage_mixed/Dockerfile @@ -0,0 +1,18 @@ +ARG BASE=ubuntu:22.04 +FROM ${BASE} AS base + +RUN apt-get update + +FROM node:18 AS builder + +WORKDIR /app +COPY . . +RUN npm ci && npm run build + +FROM scratch AS empty + +COPY --from=builder /app/dist /dist + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html From 76a3a72d2e58837231ab7446bfa2952c8f97a841 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Tue, 7 Jul 2026 13:03:33 +0300 Subject: [PATCH 4/5] fix(dockerfile): use case-insensitive scratch comparison Change "scratch".equals(image) to "scratch".equalsIgnoreCase(image) so FROM SCRATCH, FROM Scratch, and other case variants are properly skipped during Dockerfile parsing, matching the case-insensitive handling of the FROM keyword itself. Fixes: TC-5098 Assisted-by: Claude Code --- .../trustifyda/providers/DockerfileProvider.java | 2 +- .../providers/Dockerfile_Provider_Test.java | 11 +++++++++++ .../dockerfile/scratch_uppercase/Dockerfile | 8 ++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/tst_manifests/dockerfile/scratch_uppercase/Dockerfile diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java index 1f4134f6..4b03c1b6 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java @@ -137,7 +137,7 @@ static List parseAllFromImages(Path dockerfile) throws IOException { continue; } } - if ("scratch".equals(image)) { + if ("scratch".equalsIgnoreCase(image)) { LOG.info(String.format("Skipping FROM scratch in %s", dockerfile)); continue; } 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 index de23db98..a027c463 100644 --- 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 @@ -217,6 +217,17 @@ void skips_scratch_and_returns_remaining_images() throws IOException { assertThat(images).doesNotContain("scratch"); } + /** Verifies that FROM SCRATCH (uppercase) is skipped case-insensitively. */ + @Test + void skips_scratch_case_insensitively() throws IOException { + var dockerfile = TEST_MANIFESTS.resolve("scratch_uppercase/Dockerfile"); + + List images = DockerfileProvider.parseAllFromImages(dockerfile); + + assertThat(images).hasSize(1).containsExactly("node:18"); + assertThat(images).doesNotContain("SCRATCH"); + } + /** Verifies that a Dockerfile with no analyzable FROM instructions throws IOException. */ @Test void throws_when_no_analyzable_from_instruction() { diff --git a/src/test/resources/tst_manifests/dockerfile/scratch_uppercase/Dockerfile b/src/test/resources/tst_manifests/dockerfile/scratch_uppercase/Dockerfile new file mode 100644 index 00000000..bb6c13cc --- /dev/null +++ b/src/test/resources/tst_manifests/dockerfile/scratch_uppercase/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18 AS builder + +WORKDIR /app +RUN npm ci + +FROM SCRATCH + +COPY --from=builder /app/dist / From bdc3e416e58975ae50887351d9202bb373f401dd Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Thu, 9 Jul 2026 16:02:32 +0300 Subject: [PATCH 5/5] fix(cli): route Dockerfile analysis through imageAnalysis() for per-image results Dockerfile/Containerfile manifests now use imageAnalysis() instead of stackAnalysis(), preserving per-image Map output. This prevents multi-FROM results from being collapsed to a single report. - Add DockerfileProvider.parseImageRefs() to convert FROM lines to ImageRefs - Detect Dockerfile in CLI before command routing, redirect to image analysis - Use image full names as JSON keys in formatImageAnalysisResult() - Block sbom/license commands for Dockerfiles with clear error message - Make Ecosystem.isDockerfile() public for CLI detection Co-Authored-By: Claude Opus 4.6 --- .../io/github/guacsec/trustifyda/cli/App.java | 23 +++- .../providers/DockerfileProvider.java | 29 ++++ .../guacsec/trustifyda/tools/Ecosystem.java | 2 +- .../guacsec/trustifyda/cli/AppTest.java | 130 +++++++++++++++++- .../providers/Dockerfile_Provider_Test.java | 73 ++++++++++ 5 files changed, 251 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/cli/App.java b/src/main/java/io/github/guacsec/trustifyda/cli/App.java index 080e12e8..981a5774 100644 --- a/src/main/java/io/github/guacsec/trustifyda/cli/App.java +++ b/src/main/java/io/github/guacsec/trustifyda/cli/App.java @@ -33,6 +33,7 @@ import io.github.guacsec.trustifyda.impl.ExhortApi; import io.github.guacsec.trustifyda.license.ProjectLicense; import io.github.guacsec.trustifyda.license.ProjectLicense.ProjectLicenseInfo; +import io.github.guacsec.trustifyda.providers.DockerfileProvider; import io.github.guacsec.trustifyda.tools.Ecosystem; import java.io.IOException; import java.nio.file.Files; @@ -219,6 +220,16 @@ private static Path validateFile(String filePath) { } private static CompletableFuture executeCommand(CliArgs args) throws IOException { + if (isDockerfilePath(args.filePath)) { + if (args.command == Command.SBOM || args.command == Command.LICENSE) { + throw new IllegalArgumentException( + args.command + + " command is not supported for Dockerfiles/Containerfiles." + + " Use 'stack' or 'component' to analyze container images."); + } + Set imageRefs = DockerfileProvider.parseImageRefs(args.filePath); + return executeImageAnalysis(imageRefs, args.outputFormat); + } return switch (args.command) { case STACK -> executeStackAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat); @@ -230,6 +241,10 @@ private static CompletableFuture executeCommand(CliArgs args) throws IOE }; } + private static boolean isDockerfilePath(Path filePath) { + return filePath != null && Ecosystem.isDockerfile(filePath.getFileName().toString()); + } + private static CompletableFuture executeStackAnalysis( String filePath, OutputFormat outputFormat) throws IOException { Api api = new ExhortApi(); @@ -338,7 +353,11 @@ private static CompletableFuture executeSbomGeneration(CliArgs args) thr private static String formatImageAnalysisResult(Map analysisResults) { try { - return MAPPER.writeValueAsString(analysisResults); + Map keyed = new LinkedHashMap<>(); + for (Map.Entry entry : analysisResults.entrySet()) { + keyed.put(entry.getKey().getImage().getFullName(), entry.getValue()); + } + return MAPPER.writeValueAsString(keyed); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to serialize image analysis results", e); } @@ -349,7 +368,7 @@ private static Map> extractImageSummary( Map> imageSummaries = new HashMap<>(); for (Map.Entry entry : analysisResults.entrySet()) { - String imageKey = entry.getKey().toString(); + String imageKey = entry.getKey().getImage().getFullName(); Map imageSummary = extractSummary(entry.getValue()); imageSummaries.put(imageKey, imageSummary); } diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java index 4b03c1b6..48035437 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java @@ -30,8 +30,10 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -98,6 +100,33 @@ private Content generateBatchSbomContent() throws IOException { return new Content(batchBytes, Api.CYCLONEDX_MEDIA_TYPE, true); } + /** + * Parses a Dockerfile/Containerfile and returns {@link ImageRef} objects for all FROM images. + * Reuses {@link #parseAllFromImages(Path)} for FROM extraction and ARG resolution, then converts + * each image string to an {@link ImageRef} via {@link ImageUtils#parseImageRef(String)}. + * + * @param dockerfile path to the Dockerfile or Containerfile + * @return set of image references preserving FROM order + * @throws IOException if the file cannot be read or no images are analyzable + */ + public static Set parseImageRefs(Path dockerfile) throws IOException { + List imageReferences = parseAllFromImages(dockerfile); + Set imageRefs = new LinkedHashSet<>(); + for (String imageReference : imageReferences) { + try { + ImageRef imageRef = ImageUtils.parseImageRef(imageReference); + imageRefs.add(imageRef); + } catch (Exception e) { + LOG.warning( + String.format("Skipping image %s due to error: %s", imageReference, e.getMessage())); + } + } + if (imageRefs.isEmpty()) { + throw new IOException("No analyzable FROM images found in " + dockerfile); + } + return imageRefs; + } + /** * Parses a Dockerfile/Containerfile and extracts image references from all FROM instructions. * Resolves ARG substitutions using default values when available. Skips FROM lines with 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 db60ec84..b03024ff 100644 --- a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java +++ b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java @@ -100,7 +100,7 @@ private static Provider resolveProvider(final Path manifestPath) { }; } - private static boolean isDockerfile(String filename) { + public static boolean isDockerfile(String filename) { return filename.equals("Dockerfile") || filename.equals("Containerfile") || filename.startsWith("Dockerfile.") diff --git a/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java b/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java index c13c363e..909471a4 100644 --- a/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java @@ -36,14 +36,18 @@ import io.github.guacsec.trustifyda.api.v5.Scanned; import io.github.guacsec.trustifyda.api.v5.Source; import io.github.guacsec.trustifyda.api.v5.SourceSummary; +import io.github.guacsec.trustifyda.image.Image; +import io.github.guacsec.trustifyda.image.ImageRef; import io.github.guacsec.trustifyda.image.ImageUtils; import io.github.guacsec.trustifyda.impl.ExhortApi; +import io.github.guacsec.trustifyda.providers.DockerfileProvider; import java.io.IOException; import java.lang.reflect.Method; import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -836,6 +840,9 @@ void executeImageAnalysis_with_json_format_should_complete_successfully() throws Set imageRefs = new HashSet<>(); io.github.guacsec.trustifyda.image.ImageRef mockImageRef = mock(io.github.guacsec.trustifyda.image.ImageRef.class); + Image mockImage = mock(Image.class); + when(mockImage.getFullName()).thenReturn("nginx:latest"); + when(mockImageRef.getImage()).thenReturn(mockImage); imageRefs.add(mockImageRef); Map mockResults = new HashMap<>(); @@ -897,6 +904,9 @@ void executeImageAnalysis_with_summary_format_should_complete_successfully() thr Set imageRefs = new HashSet<>(); io.github.guacsec.trustifyda.image.ImageRef mockImageRef = mock(io.github.guacsec.trustifyda.image.ImageRef.class); + Image mockImage = mock(Image.class); + when(mockImage.getFullName()).thenReturn("nginx:latest"); + when(mockImageRef.getImage()).thenReturn(mockImage); imageRefs.add(mockImageRef); Map mockResults = new HashMap<>(); @@ -927,7 +937,9 @@ void executeImageAnalysis_with_summary_format_should_complete_successfully() thr void formatImageAnalysisResult_should_serialize_to_json() throws Exception { io.github.guacsec.trustifyda.image.ImageRef mockImageRef = mock(io.github.guacsec.trustifyda.image.ImageRef.class); - when(mockImageRef.toString()).thenReturn("nginx:latest"); + Image mockImage = mock(Image.class); + when(mockImage.getFullName()).thenReturn("nginx:latest"); + when(mockImageRef.getImage()).thenReturn(mockImage); Map analysisResults = new HashMap<>(); @@ -949,8 +961,12 @@ void extractImageSummary_should_extract_summaries_for_all_images() throws Except mock(io.github.guacsec.trustifyda.image.ImageRef.class); io.github.guacsec.trustifyda.image.ImageRef mockImageRef2 = mock(io.github.guacsec.trustifyda.image.ImageRef.class); - when(mockImageRef1.toString()).thenReturn("nginx:latest"); - when(mockImageRef2.toString()).thenReturn("redis:alpine"); + Image mockImage1 = mock(Image.class); + when(mockImage1.getFullName()).thenReturn("nginx:latest"); + when(mockImageRef1.getImage()).thenReturn(mockImage1); + Image mockImage2 = mock(Image.class); + when(mockImage2.getFullName()).thenReturn("redis:alpine"); + when(mockImageRef2.getImage()).thenReturn(mockImage2); Map analysisResults = new HashMap<>(); @@ -975,6 +991,9 @@ void executeCommand_with_image_analysis_should_complete_successfully() throws Ex Set imageRefs = new HashSet<>(); io.github.guacsec.trustifyda.image.ImageRef mockImageRef = mock(io.github.guacsec.trustifyda.image.ImageRef.class); + Image mockImage = mock(Image.class); + when(mockImage.getFullName()).thenReturn("nginx:latest"); + when(mockImageRef.getImage()).thenReturn(mockImage); imageRefs.add(mockImageRef); CliArgs imageArgs = new CliArgs(Command.IMAGE, imageRefs, OutputFormat.JSON); @@ -1104,4 +1123,109 @@ void parseCommand_with_sbom_should_return_sbom_command() throws Exception { Command result = (Command) parseCommandMethod.invoke(null, "sbom"); assertThat(result).isEqualTo(Command.SBOM); } + + @Test + void executeCommand_with_stack_dockerfile_routes_to_image_analysis() throws Exception { + CliArgs args = + new CliArgs(Command.STACK, Paths.get("/test/path/Dockerfile"), OutputFormat.JSON); + + ImageRef mockImageRef = mock(ImageRef.class); + Image mockImage = mock(Image.class); + when(mockImage.getFullName()).thenReturn("node:22"); + when(mockImageRef.getImage()).thenReturn(mockImage); + Set imageRefs = new LinkedHashSet<>(); + imageRefs.add(mockImageRef); + + Map mockResults = new HashMap<>(); + mockResults.put(mockImageRef, defaultAnalysisReport()); + + try (MockedStatic dockerMock = mockStatic(DockerfileProvider.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.imageAnalysis(any(Set.class))) + .thenReturn(CompletableFuture.completedFuture(mockResults)); + })) { + + dockerMock + .when(() -> DockerfileProvider.parseImageRefs(any(Path.class))) + .thenReturn(imageRefs); + + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + assertThat(result).isNotNull(); + assertThat(result.get()).isNotNull(); + dockerMock.verify(() -> DockerfileProvider.parseImageRefs(any(Path.class))); + } + } + + @Test + void executeCommand_with_component_dockerfile_routes_to_image_analysis() throws Exception { + CliArgs args = + new CliArgs(Command.COMPONENT, Paths.get("/test/path/Dockerfile"), OutputFormat.JSON); + + ImageRef mockImageRef = mock(ImageRef.class); + Image mockImage = mock(Image.class); + when(mockImage.getFullName()).thenReturn("node:22"); + when(mockImageRef.getImage()).thenReturn(mockImage); + Set imageRefs = new LinkedHashSet<>(); + imageRefs.add(mockImageRef); + + Map mockResults = new HashMap<>(); + mockResults.put(mockImageRef, defaultAnalysisReport()); + + try (MockedStatic dockerMock = mockStatic(DockerfileProvider.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.imageAnalysis(any(Set.class))) + .thenReturn(CompletableFuture.completedFuture(mockResults)); + })) { + + dockerMock + .when(() -> DockerfileProvider.parseImageRefs(any(Path.class))) + .thenReturn(imageRefs); + + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + assertThat(result).isNotNull(); + assertThat(result.get()).isNotNull(); + dockerMock.verify(() -> DockerfileProvider.parseImageRefs(any(Path.class))); + } + } + + @Test + void executeCommand_with_stack_non_dockerfile_uses_stack_analysis() throws Exception { + CliArgs args = new CliArgs(Command.STACK, TEST_FILE, OutputFormat.JSON); + + AnalysisReport mockReport = mock(AnalysisReport.class); + + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + assertThat(result).isNotNull(); + assertThat(result.get()).isNotNull(); + } + } } 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 index a027c463..1571e8ed 100644 --- 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 @@ -30,6 +30,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -259,6 +260,78 @@ void parses_containerfile() throws IOException { } } + @Nested + class ParseImageRefs { + + /** Verifies that a single-stage Dockerfile returns one ImageRef. */ + @Test + void returns_single_image_ref_for_single_from_dockerfile() throws Exception { + var dockerfile = TEST_MANIFESTS.resolve("single_stage/Dockerfile"); + ImageRef imageRef = Mockito.mock(ImageRef.class); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock + .when(() -> ImageUtils.parseImageRef("registry.access.redhat.com/ubi9/ubi-minimal:9.4")) + .thenReturn(imageRef); + + Set result = DockerfileProvider.parseImageRefs(dockerfile); + + assertThat(result).hasSize(1).containsExactly(imageRef); + } + } + + /** Verifies that a multi-stage Dockerfile returns all ImageRefs in FROM order. */ + @Test + void returns_all_image_refs_for_multi_stage_dockerfile() throws Exception { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); + ImageRef nodeRef = Mockito.mock(ImageRef.class); + ImageRef nginxRef = Mockito.mock(ImageRef.class); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock.when(() -> ImageUtils.parseImageRef("node:18")).thenReturn(nodeRef); + imageUtilsMock.when(() -> ImageUtils.parseImageRef("nginx:alpine")).thenReturn(nginxRef); + + Set result = DockerfileProvider.parseImageRefs(dockerfile); + + assertThat(result).hasSize(2).containsExactly(nodeRef, nginxRef); + } + } + + /** Verifies that failing images are skipped and remaining ImageRefs are returned. */ + @Test + void skips_failing_images_and_returns_remaining() throws Exception { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); + ImageRef nginxRef = Mockito.mock(ImageRef.class); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock + .when(() -> ImageUtils.parseImageRef("node:18")) + .thenThrow(new RuntimeException("skopeo not available")); + imageUtilsMock.when(() -> ImageUtils.parseImageRef("nginx:alpine")).thenReturn(nginxRef); + + Set result = DockerfileProvider.parseImageRefs(dockerfile); + + assertThat(result).hasSize(1).containsExactly(nginxRef); + } + } + + /** Verifies that IOException is thrown when all images fail to parse. */ + @Test + void throws_when_all_images_fail() { + var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile"); + + try (MockedStatic imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) { + imageUtilsMock + .when(() -> ImageUtils.parseImageRef(Mockito.anyString())) + .thenThrow(new RuntimeException("skopeo not available")); + + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> DockerfileProvider.parseImageRefs(dockerfile)) + .withMessageContaining("No analyzable FROM images found"); + } + } + } + @Nested class BatchContent {