Skip to content

Commit e8b81cb

Browse files
committed
feat: support image scan in cli
1 parent 2890a64 commit e8b81cb

File tree

7 files changed

+241
-5
lines changed

7 files changed

+241
-5
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,22 @@ Options:
609609
- `--summary` - Output summary in JSON format
610610
- (default) - Output full report in JSON format
611611

612+
**Image Analysis**
613+
```shell
614+
java -jar trustify-da-java-client-cli.jar image <image_ref> [<image_ref>...] [--summary|--html]
615+
```
616+
Perform security analysis on the specified container image(s).
617+
618+
Arguments:
619+
- `<image_ref>` - Container image reference (e.g., `nginx:latest`, `registry.io/image:tag`)
620+
- Multiple images can be analyzed at once
621+
- Optionally specify platform with `^^` notation (e.g., `image:tag^^linux/amd64`)
622+
623+
Options:
624+
- `--summary` - Output summary in JSON format
625+
- `--html` - Output full report in HTML format
626+
- (default) - Output full report in JSON format
627+
612628
#### Backend Configuration
613629

614630
The client requires the backend URL to be configured through the environment variable:
@@ -637,6 +653,18 @@ java -jar trustify-da-java-client-cli.jar component /path/to/requirements.txt
637653
# Component analysis with summary
638654
java -jar trustify-da-java-client-cli.jar component /path/to/go.mod --summary
639655

656+
# Container image analysis with JSON output (default)
657+
java -jar trustify-da-java-client-cli.jar image nginx:latest
658+
659+
# Multiple container image analysis
660+
java -jar trustify-da-java-client-cli.jar image nginx:latest docker.io/library/node:18
661+
662+
# Container image analysis with platform specification
663+
java -jar trustify-da-java-client-cli.jar image nginx:latest^^linux/amd64 --summary
664+
665+
# Container image analysis with HTML output
666+
java -jar trustify-da-java-client-cli.jar image quay.io/redhat/ubi8:latest --html
667+
640668
# Show help
641669
java -jar trustify-da-java-client-cli.jar --help
642670
```

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

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@
2727
import io.github.guacsec.trustifyda.api.v5.AnalysisReport;
2828
import io.github.guacsec.trustifyda.api.v5.ProviderReport;
2929
import io.github.guacsec.trustifyda.api.v5.SourceSummary;
30+
import io.github.guacsec.trustifyda.image.ImageRef;
31+
import io.github.guacsec.trustifyda.image.ImageUtils;
3032
import io.github.guacsec.trustifyda.impl.ExhortApi;
3133
import java.io.IOException;
3234
import java.nio.file.Files;
3335
import java.nio.file.Path;
3436
import java.nio.file.Paths;
3537
import java.util.HashMap;
38+
import java.util.HashSet;
3639
import java.util.Map;
40+
import java.util.Set;
3741
import java.util.concurrent.CompletableFuture;
3842
import java.util.concurrent.ExecutionException;
3943

@@ -82,25 +86,81 @@ private static CliArgs parseArgs(String[] args) {
8286

8387
Command command = parseCommand(args[0]);
8488

89+
switch (command) {
90+
case STACK:
91+
case COMPONENT:
92+
return parseFileBasedArgs(command, args);
93+
case IMAGE:
94+
return parseImageBasedArgs(command, args);
95+
default:
96+
throw new IllegalArgumentException("Unsupported command: " + command);
97+
}
98+
}
99+
100+
private static CliArgs parseFileBasedArgs(Command command, String[] args) {
101+
if (args.length < 2) {
102+
throw new IllegalArgumentException("Missing required file path for " + command + " command");
103+
}
104+
85105
Path path = validateFile(args[1]);
86106

87107
OutputFormat outputFormat = OutputFormat.JSON;
88108
if (args.length == 3) {
89109
outputFormat = parseOutputFormat(command, args[2]);
110+
} else if (args.length > 3) {
111+
throw new IllegalArgumentException("Too many arguments for " + command + " command");
90112
}
91113

92114
return new CliArgs(command, path, outputFormat);
93115
}
94116

117+
private static CliArgs parseImageBasedArgs(Command command, String[] args) {
118+
if (args.length < 2) {
119+
throw new IllegalArgumentException(
120+
"Missing required image references for " + command + " command");
121+
}
122+
123+
OutputFormat outputFormat = OutputFormat.JSON;
124+
int imageArgCount = args.length - 1;
125+
126+
if (args.length >= 3) {
127+
String lastArg = args[args.length - 1];
128+
if (lastArg.startsWith("--")) {
129+
outputFormat = parseOutputFormat(command, lastArg);
130+
imageArgCount = args.length - 2;
131+
}
132+
}
133+
134+
if (imageArgCount < 1) {
135+
throw new IllegalArgumentException(
136+
"At least one image reference is required for " + command + " command");
137+
}
138+
139+
Set<ImageRef> imageRefs = new HashSet<>();
140+
for (int i = 1; i <= imageArgCount; i++) {
141+
try {
142+
ImageRef imageRef = ImageUtils.parseImageRef(args[i]);
143+
imageRefs.add(imageRef);
144+
} catch (Exception e) {
145+
throw new IllegalArgumentException(
146+
"Invalid image reference '" + args[i] + "': " + e.getMessage(), e);
147+
}
148+
}
149+
150+
return new CliArgs(command, imageRefs, outputFormat);
151+
}
152+
95153
private static Command parseCommand(String commandStr) {
96154
switch (commandStr) {
97155
case "stack":
98156
return Command.STACK;
99157
case "component":
100158
return Command.COMPONENT;
159+
case "image":
160+
return Command.IMAGE;
101161
default:
102162
throw new IllegalArgumentException(
103-
"Unknown command: " + commandStr + ". Use 'stack' or 'component'");
163+
"Unknown command: " + commandStr + ". Use 'stack', 'component', or 'image'");
104164
}
105165
}
106166

@@ -109,8 +169,9 @@ private static OutputFormat parseOutputFormat(Command command, String formatArg)
109169
case "--summary":
110170
return OutputFormat.SUMMARY;
111171
case "--html":
112-
if (command != Command.STACK) {
113-
throw new IllegalArgumentException("HTML format is only supported for stack command");
172+
if (command != Command.STACK && command != Command.IMAGE) {
173+
throw new IllegalArgumentException(
174+
"HTML format is only supported for stack and image commands");
114175
}
115176
return OutputFormat.HTML;
116177
default:
@@ -137,6 +198,8 @@ private static CompletableFuture<String> executeCommand(CliArgs args) throws IOE
137198
case COMPONENT:
138199
return executeComponentAnalysis(
139200
args.filePath.toAbsolutePath().toString(), args.outputFormat);
201+
case IMAGE:
202+
return executeImageAnalysis(args.imageRefs, args.outputFormat);
140203
default:
141204
throw new AssertionError();
142205
}
@@ -178,6 +241,44 @@ private static String toJsonString(Object obj) {
178241
}
179242
}
180243

244+
private static CompletableFuture<String> executeImageAnalysis(
245+
Set<ImageRef> imageRefs, OutputFormat outputFormat) throws IOException {
246+
Api api = new ExhortApi();
247+
switch (outputFormat) {
248+
case JSON:
249+
return api.imageAnalysis(imageRefs).thenApply(App::formatImageAnalysisResult);
250+
case HTML:
251+
return api.imageAnalysisHtml(imageRefs).thenApply(bytes -> new String(bytes));
252+
case SUMMARY:
253+
return api.imageAnalysis(imageRefs)
254+
.thenApply(App::extractImageSummary)
255+
.thenApply(App::toJsonString);
256+
default:
257+
throw new AssertionError();
258+
}
259+
}
260+
261+
private static String formatImageAnalysisResult(Map<ImageRef, AnalysisReport> analysisResults) {
262+
try {
263+
return MAPPER.writeValueAsString(analysisResults);
264+
} catch (JsonProcessingException e) {
265+
throw new RuntimeException("Failed to serialize image analysis results", e);
266+
}
267+
}
268+
269+
private static Map<String, Map<String, SourceSummary>> extractImageSummary(
270+
Map<ImageRef, AnalysisReport> analysisResults) {
271+
Map<String, Map<String, SourceSummary>> imageSummaries = new HashMap<>();
272+
273+
for (Map.Entry<ImageRef, AnalysisReport> entry : analysisResults.entrySet()) {
274+
String imageKey = entry.getKey().toString();
275+
Map<String, SourceSummary> imageSummary = extractSummary(entry.getValue());
276+
imageSummaries.put(imageKey, imageSummary);
277+
}
278+
279+
return imageSummaries;
280+
}
281+
181282
private static Map<String, SourceSummary> extractSummary(AnalysisReport report) {
182283
Map<String, SourceSummary> summary = new HashMap<>();
183284
if (report.getProviders() == null) {

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,27 @@
1616
*/
1717
package io.github.guacsec.trustifyda.cli;
1818

19+
import io.github.guacsec.trustifyda.image.ImageRef;
1920
import java.nio.file.Path;
21+
import java.util.Set;
2022

2123
public class CliArgs {
2224
public final Command command;
2325
public final Path filePath;
26+
public final Set<ImageRef> imageRefs;
2427
public final OutputFormat outputFormat;
2528

2629
public CliArgs(Command command, Path filePath, OutputFormat outputFormat) {
2730
this.command = command;
2831
this.filePath = filePath;
32+
this.imageRefs = null;
33+
this.outputFormat = outputFormat;
34+
}
35+
36+
public CliArgs(Command command, Set<ImageRef> imageRefs, OutputFormat outputFormat) {
37+
this.command = command;
38+
this.filePath = null;
39+
this.imageRefs = imageRefs;
2940
this.outputFormat = outputFormat;
3041
}
3142
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@
1818

1919
public enum Command {
2020
STACK,
21-
COMPONENT
21+
COMPONENT,
22+
IMAGE
2223
}

src/main/java/io/github/guacsec/trustifyda/image/ImageUtils.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,45 @@ static String updatePATHEnv(String execPath) {
105105
}
106106
}
107107

108+
/**
109+
* Parse an image reference string that may contain architecture specification using ^^ notation.
110+
* Examples: - "docker.io/library/node:18" -> ImageRef with no specific platform -
111+
* "docker.io/library/node:18^^amd64" -> ImageRef with amd64 platform -
112+
* "httpd:2.4.49^^linux/amd64" -> ImageRef with linux/amd64 platform
113+
*
114+
* @param imageRefString the image reference string
115+
* @return ImageRef object
116+
* @throws IllegalArgumentException if the format is invalid
117+
*/
118+
public static ImageRef parseImageRef(String imageRefString) {
119+
if (imageRefString == null || imageRefString.trim().isEmpty()) {
120+
throw new IllegalArgumentException("Image reference cannot be null or empty");
121+
}
122+
123+
String[] parts = imageRefString.split("\\^\\^", 2);
124+
125+
if (parts.length == 1) {
126+
// Simple case: "docker.io/library/node:18"
127+
return new ImageRef(parts[0].trim(), null);
128+
} else if (parts.length == 2) {
129+
// Architecture case: "docker.io/library/node:18^^amd64"
130+
String imageRef = parts[0].trim();
131+
String platform = parts[1].trim();
132+
if (imageRef.isEmpty()) {
133+
throw new IllegalArgumentException("Image reference cannot be empty before ^^");
134+
}
135+
if (platform.isEmpty()) {
136+
throw new IllegalArgumentException("Platform specification cannot be empty after ^^");
137+
}
138+
return new ImageRef(imageRef, platform);
139+
} else {
140+
throw new IllegalArgumentException(
141+
"Invalid image reference format: "
142+
+ imageRefString
143+
+ ". Use 'image' or 'image^^platform'");
144+
}
145+
}
146+
108147
public static JsonNode generateImageSBOM(ImageRef imageRef)
109148
throws IOException, MalformedPackageURLException {
110149
var output = execSyft(imageRef);

src/main/resources/cli_help.txt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Dependency Analytics Java API CLI
22

33
USAGE:
4-
java -jar trustify-da-java-client-cli.jar <COMMAND> <FILE_PATH> [OPTIONS]
4+
java -jar trustify-da-java-client-cli.jar <COMMAND> <ARGUMENTS> [OPTIONS]
55

66
COMMANDS:
77
stack <file_path> [--summary|--html]
@@ -17,6 +17,17 @@ COMMANDS:
1717
--summary Output summary in JSON format
1818
(default) Output full report in JSON format
1919

20+
image <image_ref> [<image_ref>...] [--summary|--html]
21+
Perform security analysis on the specified container image(s)
22+
Arguments:
23+
<image_ref> Container image reference (e.g., nginx:latest, registry.io/image:tag)
24+
Multiple images can be analyzed at once
25+
Optionally specify platform with ^^ notation (e.g., image:tag^^linux/amd64)
26+
Options:
27+
--summary Output summary in JSON format
28+
--html Output full report in HTML format
29+
(default) Output full report in JSON format
30+
2031
OPTIONS:
2132
-h, --help Show this help message
2233

@@ -26,7 +37,14 @@ ENVIRONMENT VARIABLES:
2637
EXAMPLES:
2738
export TRUSTIFY_DA_BACKEND_URL=https://your-backend.url
2839

40+
# File-based analysis
2941
java -jar trustify-da-java-client-cli.jar stack /path/to/pom.xml
3042
java -jar trustify-da-java-client-cli.jar stack /path/to/package.json --summary
3143
java -jar trustify-da-java-client-cli.jar stack /path/to/build.gradle --html
3244
java -jar trustify-da-java-client-cli.jar component /path/to/requirements.txt
45+
46+
# Container image analysis
47+
java -jar trustify-da-java-client-cli.jar image nginx:latest
48+
java -jar trustify-da-java-client-cli.jar image nginx:latest docker.io/library/node:18
49+
java -jar trustify-da-java-client-cli.jar image nginx:latest^^linux/amd64 --summary
50+
java -jar trustify-da-java-client-cli.jar image quay.io/redhat/ubi8:latest --html

src/test/java/io/github/guacsec/trustifyda/image/ImageUtilsTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,4 +1109,42 @@ void test_get_single_image_digest_empty() throws JsonProcessingException {
11091109
assertEquals(expectedDigests, digests);
11101110
}
11111111
}
1112+
1113+
@Test
1114+
void test_parseImageRef_withDigests() {
1115+
// Test simple case - image without platform specification
1116+
String imageWithDigest =
1117+
"nginx:1.21@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1";
1118+
ImageRef result = ImageUtils.parseImageRef(imageWithDigest);
1119+
1120+
assertEquals(
1121+
"nginx:1.21@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
1122+
result.getImage().getFullName());
1123+
assertNull(result.getPlatform());
1124+
1125+
// Test with platform specification using ^^ notation
1126+
String imageWithPlatform =
1127+
"alpine:3.14@sha256:def456abc123def456abc123def456abc123def456abc123def456abc123def4^^linux/amd64";
1128+
result = ImageUtils.parseImageRef(imageWithPlatform);
1129+
1130+
assertEquals(
1131+
"alpine:3.14@sha256:def456abc123def456abc123def456abc123def456abc123def456abc123def4",
1132+
result.getImage().getFullName());
1133+
assertEquals("linux/amd64", result.getPlatform().toString());
1134+
1135+
// Test with complex registry and platform
1136+
String complexImage =
1137+
"quay.io/redhat/ubi8:8.9@sha256:fedcba987654fedcba987654fedcba987654fedcba987654fedcba987654fedcba^^linux/arm64";
1138+
result = ImageUtils.parseImageRef(complexImage);
1139+
1140+
assertEquals(
1141+
"quay.io/redhat/ubi8:8.9@sha256:fedcba987654fedcba987654fedcba987654fedcba987654fedcba987654fedcba",
1142+
result.getImage().getFullName());
1143+
1144+
// Platform class may normalize arm64 to arm64/v8
1145+
String platform = result.getPlatform().toString();
1146+
assertTrue(
1147+
platform.equals("linux/arm64") || platform.equals("linux/arm64/v8"),
1148+
"Expected linux/arm64 or linux/arm64/v8, got: " + platform);
1149+
}
11121150
}

0 commit comments

Comments
 (0)