Skip to content

Commit bdc3e41

Browse files
a-orenclaude
andcommitted
fix(cli): route Dockerfile analysis through imageAnalysis() for per-image results
Dockerfile/Containerfile manifests now use imageAnalysis() instead of stackAnalysis(), preserving per-image Map<ImageRef, AnalysisReport> 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 <noreply@anthropic.com>
1 parent 76a3a72 commit bdc3e41

5 files changed

Lines changed: 251 additions & 6 deletions

File tree

src/main/java/io/github/guacsec/trustifyda/cli/App.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import io.github.guacsec.trustifyda.impl.ExhortApi;
3434
import io.github.guacsec.trustifyda.license.ProjectLicense;
3535
import io.github.guacsec.trustifyda.license.ProjectLicense.ProjectLicenseInfo;
36+
import io.github.guacsec.trustifyda.providers.DockerfileProvider;
3637
import io.github.guacsec.trustifyda.tools.Ecosystem;
3738
import java.io.IOException;
3839
import java.nio.file.Files;
@@ -219,6 +220,16 @@ private static Path validateFile(String filePath) {
219220
}
220221

221222
private static CompletableFuture<String> executeCommand(CliArgs args) throws IOException {
223+
if (isDockerfilePath(args.filePath)) {
224+
if (args.command == Command.SBOM || args.command == Command.LICENSE) {
225+
throw new IllegalArgumentException(
226+
args.command
227+
+ " command is not supported for Dockerfiles/Containerfiles."
228+
+ " Use 'stack' or 'component' to analyze container images.");
229+
}
230+
Set<ImageRef> imageRefs = DockerfileProvider.parseImageRefs(args.filePath);
231+
return executeImageAnalysis(imageRefs, args.outputFormat);
232+
}
222233
return switch (args.command) {
223234
case STACK ->
224235
executeStackAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat);
@@ -230,6 +241,10 @@ private static CompletableFuture<String> executeCommand(CliArgs args) throws IOE
230241
};
231242
}
232243

244+
private static boolean isDockerfilePath(Path filePath) {
245+
return filePath != null && Ecosystem.isDockerfile(filePath.getFileName().toString());
246+
}
247+
233248
private static CompletableFuture<String> executeStackAnalysis(
234249
String filePath, OutputFormat outputFormat) throws IOException {
235250
Api api = new ExhortApi();
@@ -338,7 +353,11 @@ private static CompletableFuture<String> executeSbomGeneration(CliArgs args) thr
338353

339354
private static String formatImageAnalysisResult(Map<ImageRef, AnalysisReport> analysisResults) {
340355
try {
341-
return MAPPER.writeValueAsString(analysisResults);
356+
Map<String, AnalysisReport> keyed = new LinkedHashMap<>();
357+
for (Map.Entry<ImageRef, AnalysisReport> entry : analysisResults.entrySet()) {
358+
keyed.put(entry.getKey().getImage().getFullName(), entry.getValue());
359+
}
360+
return MAPPER.writeValueAsString(keyed);
342361
} catch (JsonProcessingException e) {
343362
throw new RuntimeException("Failed to serialize image analysis results", e);
344363
}
@@ -349,7 +368,7 @@ private static Map<String, Map<String, SourceSummary>> extractImageSummary(
349368
Map<String, Map<String, SourceSummary>> imageSummaries = new HashMap<>();
350369

351370
for (Map.Entry<ImageRef, AnalysisReport> entry : analysisResults.entrySet()) {
352-
String imageKey = entry.getKey().toString();
371+
String imageKey = entry.getKey().getImage().getFullName();
353372
Map<String, SourceSummary> imageSummary = extractSummary(entry.getValue());
354373
imageSummaries.put(imageKey, imageSummary);
355374
}

src/main/java/io/github/guacsec/trustifyda/providers/DockerfileProvider.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@
3030
import java.util.ArrayList;
3131
import java.util.HashMap;
3232
import java.util.LinkedHashMap;
33+
import java.util.LinkedHashSet;
3334
import java.util.List;
3435
import java.util.Map;
36+
import java.util.Set;
3537
import java.util.logging.Logger;
3638
import java.util.regex.Matcher;
3739
import java.util.regex.Pattern;
@@ -98,6 +100,33 @@ private Content generateBatchSbomContent() throws IOException {
98100
return new Content(batchBytes, Api.CYCLONEDX_MEDIA_TYPE, true);
99101
}
100102

103+
/**
104+
* Parses a Dockerfile/Containerfile and returns {@link ImageRef} objects for all FROM images.
105+
* Reuses {@link #parseAllFromImages(Path)} for FROM extraction and ARG resolution, then converts
106+
* each image string to an {@link ImageRef} via {@link ImageUtils#parseImageRef(String)}.
107+
*
108+
* @param dockerfile path to the Dockerfile or Containerfile
109+
* @return set of image references preserving FROM order
110+
* @throws IOException if the file cannot be read or no images are analyzable
111+
*/
112+
public static Set<ImageRef> parseImageRefs(Path dockerfile) throws IOException {
113+
List<String> imageReferences = parseAllFromImages(dockerfile);
114+
Set<ImageRef> imageRefs = new LinkedHashSet<>();
115+
for (String imageReference : imageReferences) {
116+
try {
117+
ImageRef imageRef = ImageUtils.parseImageRef(imageReference);
118+
imageRefs.add(imageRef);
119+
} catch (Exception e) {
120+
LOG.warning(
121+
String.format("Skipping image %s due to error: %s", imageReference, e.getMessage()));
122+
}
123+
}
124+
if (imageRefs.isEmpty()) {
125+
throw new IOException("No analyzable FROM images found in " + dockerfile);
126+
}
127+
return imageRefs;
128+
}
129+
101130
/**
102131
* Parses a Dockerfile/Containerfile and extracts image references from all FROM instructions.
103132
* Resolves ARG substitutions using default values when available. Skips FROM lines with

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ private static Provider resolveProvider(final Path manifestPath) {
100100
};
101101
}
102102

103-
private static boolean isDockerfile(String filename) {
103+
public static boolean isDockerfile(String filename) {
104104
return filename.equals("Dockerfile")
105105
|| filename.equals("Containerfile")
106106
|| filename.startsWith("Dockerfile.")

src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,18 @@
3636
import io.github.guacsec.trustifyda.api.v5.Scanned;
3737
import io.github.guacsec.trustifyda.api.v5.Source;
3838
import io.github.guacsec.trustifyda.api.v5.SourceSummary;
39+
import io.github.guacsec.trustifyda.image.Image;
40+
import io.github.guacsec.trustifyda.image.ImageRef;
3941
import io.github.guacsec.trustifyda.image.ImageUtils;
4042
import io.github.guacsec.trustifyda.impl.ExhortApi;
43+
import io.github.guacsec.trustifyda.providers.DockerfileProvider;
4144
import java.io.IOException;
4245
import java.lang.reflect.Method;
4346
import java.nio.file.Path;
4447
import java.nio.file.Paths;
4548
import java.util.HashMap;
4649
import java.util.HashSet;
50+
import java.util.LinkedHashSet;
4751
import java.util.Map;
4852
import java.util.Set;
4953
import java.util.concurrent.CompletableFuture;
@@ -836,6 +840,9 @@ void executeImageAnalysis_with_json_format_should_complete_successfully() throws
836840
Set<io.github.guacsec.trustifyda.image.ImageRef> imageRefs = new HashSet<>();
837841
io.github.guacsec.trustifyda.image.ImageRef mockImageRef =
838842
mock(io.github.guacsec.trustifyda.image.ImageRef.class);
843+
Image mockImage = mock(Image.class);
844+
when(mockImage.getFullName()).thenReturn("nginx:latest");
845+
when(mockImageRef.getImage()).thenReturn(mockImage);
839846
imageRefs.add(mockImageRef);
840847

841848
Map<io.github.guacsec.trustifyda.image.ImageRef, AnalysisReport> mockResults = new HashMap<>();
@@ -897,6 +904,9 @@ void executeImageAnalysis_with_summary_format_should_complete_successfully() thr
897904
Set<io.github.guacsec.trustifyda.image.ImageRef> imageRefs = new HashSet<>();
898905
io.github.guacsec.trustifyda.image.ImageRef mockImageRef =
899906
mock(io.github.guacsec.trustifyda.image.ImageRef.class);
907+
Image mockImage = mock(Image.class);
908+
when(mockImage.getFullName()).thenReturn("nginx:latest");
909+
when(mockImageRef.getImage()).thenReturn(mockImage);
900910
imageRefs.add(mockImageRef);
901911

902912
Map<io.github.guacsec.trustifyda.image.ImageRef, AnalysisReport> mockResults = new HashMap<>();
@@ -927,7 +937,9 @@ void executeImageAnalysis_with_summary_format_should_complete_successfully() thr
927937
void formatImageAnalysisResult_should_serialize_to_json() throws Exception {
928938
io.github.guacsec.trustifyda.image.ImageRef mockImageRef =
929939
mock(io.github.guacsec.trustifyda.image.ImageRef.class);
930-
when(mockImageRef.toString()).thenReturn("nginx:latest");
940+
Image mockImage = mock(Image.class);
941+
when(mockImage.getFullName()).thenReturn("nginx:latest");
942+
when(mockImageRef.getImage()).thenReturn(mockImage);
931943

932944
Map<io.github.guacsec.trustifyda.image.ImageRef, AnalysisReport> analysisResults =
933945
new HashMap<>();
@@ -949,8 +961,12 @@ void extractImageSummary_should_extract_summaries_for_all_images() throws Except
949961
mock(io.github.guacsec.trustifyda.image.ImageRef.class);
950962
io.github.guacsec.trustifyda.image.ImageRef mockImageRef2 =
951963
mock(io.github.guacsec.trustifyda.image.ImageRef.class);
952-
when(mockImageRef1.toString()).thenReturn("nginx:latest");
953-
when(mockImageRef2.toString()).thenReturn("redis:alpine");
964+
Image mockImage1 = mock(Image.class);
965+
when(mockImage1.getFullName()).thenReturn("nginx:latest");
966+
when(mockImageRef1.getImage()).thenReturn(mockImage1);
967+
Image mockImage2 = mock(Image.class);
968+
when(mockImage2.getFullName()).thenReturn("redis:alpine");
969+
when(mockImageRef2.getImage()).thenReturn(mockImage2);
954970

955971
Map<io.github.guacsec.trustifyda.image.ImageRef, AnalysisReport> analysisResults =
956972
new HashMap<>();
@@ -975,6 +991,9 @@ void executeCommand_with_image_analysis_should_complete_successfully() throws Ex
975991
Set<io.github.guacsec.trustifyda.image.ImageRef> imageRefs = new HashSet<>();
976992
io.github.guacsec.trustifyda.image.ImageRef mockImageRef =
977993
mock(io.github.guacsec.trustifyda.image.ImageRef.class);
994+
Image mockImage = mock(Image.class);
995+
when(mockImage.getFullName()).thenReturn("nginx:latest");
996+
when(mockImageRef.getImage()).thenReturn(mockImage);
978997
imageRefs.add(mockImageRef);
979998

980999
CliArgs imageArgs = new CliArgs(Command.IMAGE, imageRefs, OutputFormat.JSON);
@@ -1104,4 +1123,109 @@ void parseCommand_with_sbom_should_return_sbom_command() throws Exception {
11041123
Command result = (Command) parseCommandMethod.invoke(null, "sbom");
11051124
assertThat(result).isEqualTo(Command.SBOM);
11061125
}
1126+
1127+
@Test
1128+
void executeCommand_with_stack_dockerfile_routes_to_image_analysis() throws Exception {
1129+
CliArgs args =
1130+
new CliArgs(Command.STACK, Paths.get("/test/path/Dockerfile"), OutputFormat.JSON);
1131+
1132+
ImageRef mockImageRef = mock(ImageRef.class);
1133+
Image mockImage = mock(Image.class);
1134+
when(mockImage.getFullName()).thenReturn("node:22");
1135+
when(mockImageRef.getImage()).thenReturn(mockImage);
1136+
Set<ImageRef> imageRefs = new LinkedHashSet<>();
1137+
imageRefs.add(mockImageRef);
1138+
1139+
Map<ImageRef, AnalysisReport> mockResults = new HashMap<>();
1140+
mockResults.put(mockImageRef, defaultAnalysisReport());
1141+
1142+
try (MockedStatic<DockerfileProvider> dockerMock = mockStatic(DockerfileProvider.class);
1143+
MockedConstruction<ExhortApi> mockedExhortApi =
1144+
mockConstruction(
1145+
ExhortApi.class,
1146+
(mock, context) -> {
1147+
when(mock.imageAnalysis(any(Set.class)))
1148+
.thenReturn(CompletableFuture.completedFuture(mockResults));
1149+
})) {
1150+
1151+
dockerMock
1152+
.when(() -> DockerfileProvider.parseImageRefs(any(Path.class)))
1153+
.thenReturn(imageRefs);
1154+
1155+
Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
1156+
executeCommandMethod.setAccessible(true);
1157+
1158+
CompletableFuture<String> result =
1159+
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);
1160+
1161+
assertThat(result).isNotNull();
1162+
assertThat(result.get()).isNotNull();
1163+
dockerMock.verify(() -> DockerfileProvider.parseImageRefs(any(Path.class)));
1164+
}
1165+
}
1166+
1167+
@Test
1168+
void executeCommand_with_component_dockerfile_routes_to_image_analysis() throws Exception {
1169+
CliArgs args =
1170+
new CliArgs(Command.COMPONENT, Paths.get("/test/path/Dockerfile"), OutputFormat.JSON);
1171+
1172+
ImageRef mockImageRef = mock(ImageRef.class);
1173+
Image mockImage = mock(Image.class);
1174+
when(mockImage.getFullName()).thenReturn("node:22");
1175+
when(mockImageRef.getImage()).thenReturn(mockImage);
1176+
Set<ImageRef> imageRefs = new LinkedHashSet<>();
1177+
imageRefs.add(mockImageRef);
1178+
1179+
Map<ImageRef, AnalysisReport> mockResults = new HashMap<>();
1180+
mockResults.put(mockImageRef, defaultAnalysisReport());
1181+
1182+
try (MockedStatic<DockerfileProvider> dockerMock = mockStatic(DockerfileProvider.class);
1183+
MockedConstruction<ExhortApi> mockedExhortApi =
1184+
mockConstruction(
1185+
ExhortApi.class,
1186+
(mock, context) -> {
1187+
when(mock.imageAnalysis(any(Set.class)))
1188+
.thenReturn(CompletableFuture.completedFuture(mockResults));
1189+
})) {
1190+
1191+
dockerMock
1192+
.when(() -> DockerfileProvider.parseImageRefs(any(Path.class)))
1193+
.thenReturn(imageRefs);
1194+
1195+
Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
1196+
executeCommandMethod.setAccessible(true);
1197+
1198+
CompletableFuture<String> result =
1199+
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);
1200+
1201+
assertThat(result).isNotNull();
1202+
assertThat(result.get()).isNotNull();
1203+
dockerMock.verify(() -> DockerfileProvider.parseImageRefs(any(Path.class)));
1204+
}
1205+
}
1206+
1207+
@Test
1208+
void executeCommand_with_stack_non_dockerfile_uses_stack_analysis() throws Exception {
1209+
CliArgs args = new CliArgs(Command.STACK, TEST_FILE, OutputFormat.JSON);
1210+
1211+
AnalysisReport mockReport = mock(AnalysisReport.class);
1212+
1213+
try (MockedConstruction<ExhortApi> mockedExhortApi =
1214+
mockConstruction(
1215+
ExhortApi.class,
1216+
(mock, context) -> {
1217+
when(mock.stackAnalysis(any(String.class)))
1218+
.thenReturn(CompletableFuture.completedFuture(mockReport));
1219+
})) {
1220+
1221+
Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
1222+
executeCommandMethod.setAccessible(true);
1223+
1224+
CompletableFuture<String> result =
1225+
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);
1226+
1227+
assertThat(result).isNotNull();
1228+
assertThat(result.get()).isNotNull();
1229+
}
1230+
}
11071231
}

src/test/java/io/github/guacsec/trustifyda/providers/Dockerfile_Provider_Test.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.io.IOException;
3131
import java.nio.file.Path;
3232
import java.util.List;
33+
import java.util.Set;
3334
import java.util.stream.Stream;
3435
import org.junit.jupiter.api.Nested;
3536
import org.junit.jupiter.api.Test;
@@ -259,6 +260,78 @@ void parses_containerfile() throws IOException {
259260
}
260261
}
261262

263+
@Nested
264+
class ParseImageRefs {
265+
266+
/** Verifies that a single-stage Dockerfile returns one ImageRef. */
267+
@Test
268+
void returns_single_image_ref_for_single_from_dockerfile() throws Exception {
269+
var dockerfile = TEST_MANIFESTS.resolve("single_stage/Dockerfile");
270+
ImageRef imageRef = Mockito.mock(ImageRef.class);
271+
272+
try (MockedStatic<ImageUtils> imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) {
273+
imageUtilsMock
274+
.when(() -> ImageUtils.parseImageRef("registry.access.redhat.com/ubi9/ubi-minimal:9.4"))
275+
.thenReturn(imageRef);
276+
277+
Set<ImageRef> result = DockerfileProvider.parseImageRefs(dockerfile);
278+
279+
assertThat(result).hasSize(1).containsExactly(imageRef);
280+
}
281+
}
282+
283+
/** Verifies that a multi-stage Dockerfile returns all ImageRefs in FROM order. */
284+
@Test
285+
void returns_all_image_refs_for_multi_stage_dockerfile() throws Exception {
286+
var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile");
287+
ImageRef nodeRef = Mockito.mock(ImageRef.class);
288+
ImageRef nginxRef = Mockito.mock(ImageRef.class);
289+
290+
try (MockedStatic<ImageUtils> imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) {
291+
imageUtilsMock.when(() -> ImageUtils.parseImageRef("node:18")).thenReturn(nodeRef);
292+
imageUtilsMock.when(() -> ImageUtils.parseImageRef("nginx:alpine")).thenReturn(nginxRef);
293+
294+
Set<ImageRef> result = DockerfileProvider.parseImageRefs(dockerfile);
295+
296+
assertThat(result).hasSize(2).containsExactly(nodeRef, nginxRef);
297+
}
298+
}
299+
300+
/** Verifies that failing images are skipped and remaining ImageRefs are returned. */
301+
@Test
302+
void skips_failing_images_and_returns_remaining() throws Exception {
303+
var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile");
304+
ImageRef nginxRef = Mockito.mock(ImageRef.class);
305+
306+
try (MockedStatic<ImageUtils> imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) {
307+
imageUtilsMock
308+
.when(() -> ImageUtils.parseImageRef("node:18"))
309+
.thenThrow(new RuntimeException("skopeo not available"));
310+
imageUtilsMock.when(() -> ImageUtils.parseImageRef("nginx:alpine")).thenReturn(nginxRef);
311+
312+
Set<ImageRef> result = DockerfileProvider.parseImageRefs(dockerfile);
313+
314+
assertThat(result).hasSize(1).containsExactly(nginxRef);
315+
}
316+
}
317+
318+
/** Verifies that IOException is thrown when all images fail to parse. */
319+
@Test
320+
void throws_when_all_images_fail() {
321+
var dockerfile = TEST_MANIFESTS.resolve("multi_stage/Dockerfile");
322+
323+
try (MockedStatic<ImageUtils> imageUtilsMock = Mockito.mockStatic(ImageUtils.class)) {
324+
imageUtilsMock
325+
.when(() -> ImageUtils.parseImageRef(Mockito.anyString()))
326+
.thenThrow(new RuntimeException("skopeo not available"));
327+
328+
assertThatExceptionOfType(IOException.class)
329+
.isThrownBy(() -> DockerfileProvider.parseImageRefs(dockerfile))
330+
.withMessageContaining("No analyzable FROM images found");
331+
}
332+
}
333+
}
334+
262335
@Nested
263336
class BatchContent {
264337

0 commit comments

Comments
 (0)