Skip to content

Commit c03a8e1

Browse files
committed
feat(api): add generateSbom API method and SBOM CLI command
Add a new public generateSbom(String manifestFile) method to the Api interface and its ExhortApi implementation. The method generates a CycloneDX SBOM from a manifest file locally using the existing provider infrastructure, without sending anything to the backend. Add a new 'sbom' CLI command with an --output flag. Without --output, the SBOM JSON is printed to stdout. With --output <path>, it is written to the specified file. Implements TC-3991 Assisted-by: Claude Code
1 parent 14fce6d commit c03a8e1

9 files changed

Lines changed: 281 additions & 5 deletions

File tree

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ public class TrustifyExample {
168168
var result = componentWithLicense.get();
169169
var report = result.report(); // standard AnalysisReport
170170
var licenseSummary = result.licenseSummary(); // license compatibility summary (may be null)
171+
172+
// generate a CycloneDX SBOM locally (no backend call required)
173+
String sbomJson = exhortApi.generateSbom("/path/to/pom.xml");
171174
}
172175
}
173176
```
@@ -670,6 +673,16 @@ java -jar trustify-da-java-client-cli.jar license <file_path>
670673
```
671674
Display project license information from manifest and LICENSE file in JSON format.
672675

676+
**SBOM Generation**
677+
```shell
678+
java -jar trustify-da-java-client-cli.jar sbom <file_path> [--output <path>]
679+
```
680+
Generate a CycloneDX SBOM from the specified manifest file locally, without sending anything to the backend.
681+
682+
Options:
683+
- `--output <path>` - Write SBOM JSON to the specified file
684+
- (default) - Print SBOM JSON to stdout
685+
673686
**Image Analysis**
674687
```shell
675688
java -jar trustify-da-java-client-cli.jar image <image_ref> [<image_ref>...] [--summary|--html]
@@ -690,9 +703,9 @@ Options:
690703

691704
The client requires the backend URL to be configured through the environment variable:
692705

693-
- **Environment variable**: `TRUSTIFY_DA_BACKEND_URL=https://backend.url` (required)
706+
- **Environment variable**: `TRUSTIFY_DA_BACKEND_URL=https://backend.url` (required for analysis commands)
694707

695-
The application will fail to start if this environment variable is not set.
708+
The application will fail to start if this environment variable is not set, except for the `sbom` command which operates locally without a backend connection.
696709

697710
#### Examples
698711

@@ -720,6 +733,10 @@ java -jar trustify-da-java-client-cli.jar component /path/to/go.mod --summary
720733
# Rust Cargo analysis
721734
java -jar trustify-da-java-client-cli.jar stack /path/to/Cargo.toml --summary
722735

736+
# SBOM generation (no backend required)
737+
java -jar trustify-da-java-client-cli.jar sbom /path/to/pom.xml
738+
java -jar trustify-da-java-client-cli.jar sbom /path/to/package.json --output sbom.json
739+
723740
# License information
724741
java -jar trustify-da-java-client-cli.jar license /path/to/pom.xml
725742

src/main/java/io/github/guacsec/trustifyda/Api.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,13 @@ CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(Set<ImageRef> ima
124124
throws IOException;
125125

126126
CompletableFuture<byte[]> imageAnalysisHtml(Set<ImageRef> imageRefs) throws IOException;
127+
128+
/**
129+
* Generate a CycloneDX SBOM from a manifest file locally without sending anything to the backend.
130+
*
131+
* @param manifestFile the path for the manifest file
132+
* @return the CycloneDX JSON SBOM content as a String
133+
* @throws IOException when failed to load the manifest file
134+
*/
135+
String generateSbom(String manifestFile) throws IOException;
127136
}

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ private static CliArgs parseArgs(String[] args) {
9494
return switch (command) {
9595
case STACK, COMPONENT, LICENSE -> parseFileBasedArgs(command, args);
9696
case IMAGE -> parseImageBasedArgs(command, args);
97+
case SBOM -> parseSbomArgs(args);
9798
};
9899
}
99100

@@ -153,17 +154,40 @@ private static CliArgs parseImageBasedArgs(Command command, String[] args) {
153154
return new CliArgs(command, imageRefs, outputFormat);
154155
}
155156

157+
private static CliArgs parseSbomArgs(String[] args) {
158+
if (args.length < 2) {
159+
throw new IllegalArgumentException("Missing required file path for sbom command");
160+
}
161+
162+
Path path = validateFile(args[1]);
163+
Path outputPath = null;
164+
165+
for (int i = 2; i < args.length; i++) {
166+
if ("--output".equals(args[i])) {
167+
if (i + 1 >= args.length) {
168+
throw new IllegalArgumentException("Missing value for --output flag");
169+
}
170+
outputPath = Paths.get(args[++i]);
171+
} else {
172+
throw new IllegalArgumentException("Unknown option for sbom command: " + args[i]);
173+
}
174+
}
175+
176+
return new CliArgs(Command.SBOM, path, outputPath);
177+
}
178+
156179
private static Command parseCommand(String commandStr) {
157180
return switch (commandStr) {
158181
case "stack" -> Command.STACK;
159182
case "component" -> Command.COMPONENT;
160183
case "image" -> Command.IMAGE;
161184
case "license" -> Command.LICENSE;
185+
case "sbom" -> Command.SBOM;
162186
default ->
163187
throw new IllegalArgumentException(
164188
"Unknown command: "
165189
+ commandStr
166-
+ ". Use 'stack', 'component', 'image', or 'license'");
190+
+ ". Use 'stack', 'component', 'image', 'license', or 'sbom'");
167191
};
168192
}
169193

@@ -202,6 +226,7 @@ private static CompletableFuture<String> executeCommand(CliArgs args) throws IOE
202226
executeComponentAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat);
203227
case IMAGE -> executeImageAnalysis(args.imageRefs, args.outputFormat);
204228
case LICENSE -> executeLicenseCheck(args.filePath.toAbsolutePath());
229+
case SBOM -> executeSbomGeneration(args);
205230
};
206231
}
207232

@@ -300,6 +325,17 @@ private static CompletableFuture<String> executeImageAnalysis(
300325
};
301326
}
302327

328+
private static CompletableFuture<String> executeSbomGeneration(CliArgs args) throws IOException {
329+
Api api = new ExhortApi();
330+
String sbomJson = api.generateSbom(args.filePath.toAbsolutePath().toString());
331+
if (args.outputPath != null) {
332+
Files.writeString(args.outputPath, sbomJson);
333+
return CompletableFuture.completedFuture(
334+
"SBOM written to " + args.outputPath.toAbsolutePath());
335+
}
336+
return CompletableFuture.completedFuture(sbomJson);
337+
}
338+
303339
private static String formatImageAnalysisResult(Map<ImageRef, AnalysisReport> analysisResults) {
304340
try {
305341
return MAPPER.writeValueAsString(analysisResults);

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,29 @@ public class CliArgs {
2525
public final Path filePath;
2626
public final Set<ImageRef> imageRefs;
2727
public final OutputFormat outputFormat;
28+
public final Path outputPath;
2829

2930
public CliArgs(Command command, Path filePath, OutputFormat outputFormat) {
3031
this.command = command;
3132
this.filePath = filePath;
3233
this.imageRefs = null;
3334
this.outputFormat = outputFormat;
35+
this.outputPath = null;
3436
}
3537

3638
public CliArgs(Command command, Set<ImageRef> imageRefs, OutputFormat outputFormat) {
3739
this.command = command;
3840
this.filePath = null;
3941
this.imageRefs = imageRefs;
4042
this.outputFormat = outputFormat;
43+
this.outputPath = null;
44+
}
45+
46+
public CliArgs(Command command, Path filePath, Path outputPath) {
47+
this.command = command;
48+
this.filePath = filePath;
49+
this.imageRefs = null;
50+
this.outputFormat = null;
51+
this.outputPath = outputPath;
4152
}
4253
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ public enum Command {
2020
STACK,
2121
COMPONENT,
2222
IMAGE,
23-
LICENSE
23+
LICENSE,
24+
SBOM
2425
}

src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,14 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType
438438
return buildRequest(content, uri, acceptType, "Stack Analysis");
439439
}
440440

441+
@Override
442+
public String generateSbom(final String manifestFile) throws IOException {
443+
var manifestPath = Path.of(manifestFile);
444+
var provider = Ecosystem.getProvider(manifestPath);
445+
var content = provider.provideStack();
446+
return new String(content.buffer, java.nio.charset.StandardCharsets.UTF_8);
447+
}
448+
441449
@Override
442450
public CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(
443451
final Set<ImageRef> imageRefs) throws IOException {

src/main/resources/cli_help.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ COMMANDS:
1818
--summary Output summary in JSON format (without license check)
1919
(default) Output full report in JSON format (includes license summary)
2020

21+
sbom <file_path> [--output <path>]
22+
Generate a CycloneDX SBOM from the specified manifest file (no backend call)
23+
Options:
24+
--output <path> Write SBOM JSON to the specified file
25+
(default) Print SBOM JSON to stdout
26+
2127
license <file_path>
2228
Display project license information from manifest and LICENSE file in JSON format
2329

@@ -49,6 +55,10 @@ EXAMPLES:
4955
java -jar trustify-da-java-client-cli.jar component /path/to/requirements.txt
5056
java -jar trustify-da-java-client-cli.jar stack /path/to/Cargo.toml
5157

58+
# SBOM generation
59+
java -jar trustify-da-java-client-cli.jar sbom /path/to/pom.xml
60+
java -jar trustify-da-java-client-cli.jar sbom /path/to/package.json --output sbom.json
61+
5262
# License information
5363
java -jar trustify-da-java-client-cli.jar license /path/to/pom.xml
5464

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

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,13 @@ void command_enum_should_have_correct_values() {
404404
assertThat(Command.COMPONENT).isNotNull();
405405
assertThat(Command.IMAGE).isNotNull();
406406
assertThat(Command.LICENSE).isNotNull();
407-
assertThat(Command.values()).hasSize(4);
407+
assertThat(Command.SBOM).isNotNull();
408+
assertThat(Command.values()).hasSize(5);
408409
assertThat(Command.valueOf("STACK")).isEqualTo(Command.STACK);
409410
assertThat(Command.valueOf("COMPONENT")).isEqualTo(Command.COMPONENT);
410411
assertThat(Command.valueOf("IMAGE")).isEqualTo(Command.IMAGE);
411412
assertThat(Command.valueOf("LICENSE")).isEqualTo(Command.LICENSE);
413+
assertThat(Command.valueOf("SBOM")).isEqualTo(Command.SBOM);
412414
}
413415

414416
@Test
@@ -1013,4 +1015,93 @@ private AnalysisReport defaultAnalysisReport() {
10131015
.putSourcesItem("osv", new Source().summary(new SourceSummary())));
10141016
return report;
10151017
}
1018+
1019+
@Test
1020+
void executeCommand_with_sbom_should_return_sbom_json() throws Exception {
1021+
CliArgs args = new CliArgs(Command.SBOM, TEST_FILE, (Path) null);
1022+
1023+
var fakeSbom = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}";
1024+
1025+
try (MockedConstruction<ExhortApi> mockedExhortApi =
1026+
mockConstruction(
1027+
ExhortApi.class,
1028+
(mock, context) -> {
1029+
when(mock.generateSbom(any(String.class))).thenReturn(fakeSbom);
1030+
})) {
1031+
1032+
Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
1033+
executeCommandMethod.setAccessible(true);
1034+
1035+
CompletableFuture<String> result =
1036+
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);
1037+
1038+
assertThat(result).isNotNull();
1039+
assertThat(result.get()).isEqualTo(fakeSbom);
1040+
}
1041+
}
1042+
1043+
@Test
1044+
void executeCommand_with_sbom_and_output_should_write_to_file() throws Exception {
1045+
var outputFile = java.nio.file.Files.createTempFile("sbom_output_", ".json");
1046+
java.nio.file.Files.deleteIfExists(outputFile);
1047+
1048+
CliArgs args = new CliArgs(Command.SBOM, TEST_FILE, outputFile);
1049+
1050+
var fakeSbom = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}";
1051+
1052+
try (MockedConstruction<ExhortApi> mockedExhortApi =
1053+
mockConstruction(
1054+
ExhortApi.class,
1055+
(mock, context) -> {
1056+
when(mock.generateSbom(any(String.class))).thenReturn(fakeSbom);
1057+
})) {
1058+
1059+
Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
1060+
executeCommandMethod.setAccessible(true);
1061+
1062+
CompletableFuture<String> result =
1063+
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);
1064+
1065+
assertThat(result).isNotNull();
1066+
assertThat(result.get()).contains("SBOM written to");
1067+
assertThat(java.nio.file.Files.readString(outputFile)).isEqualTo(fakeSbom);
1068+
} finally {
1069+
java.nio.file.Files.deleteIfExists(outputFile);
1070+
}
1071+
}
1072+
1073+
@Test
1074+
void executeCommand_with_sbom_and_unsupported_file_should_throw_exception() throws Exception {
1075+
var tmpFile = java.nio.file.Files.createTempFile("unsupported_", ".xyz");
1076+
1077+
CliArgs args = new CliArgs(Command.SBOM, tmpFile, (Path) null);
1078+
1079+
try (MockedConstruction<ExhortApi> mockedExhortApi =
1080+
mockConstruction(
1081+
ExhortApi.class,
1082+
(mock, context) -> {
1083+
when(mock.generateSbom(any(String.class)))
1084+
.thenThrow(new IllegalStateException("Unknown manifest file unsupported_.xyz"));
1085+
})) {
1086+
1087+
Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
1088+
executeCommandMethod.setAccessible(true);
1089+
1090+
assertThatThrownBy(() -> executeCommandMethod.invoke(null, args))
1091+
.cause()
1092+
.isInstanceOf(IllegalStateException.class)
1093+
.hasMessageContaining("Unknown manifest file");
1094+
} finally {
1095+
java.nio.file.Files.deleteIfExists(tmpFile);
1096+
}
1097+
}
1098+
1099+
@Test
1100+
void parseCommand_with_sbom_should_return_sbom_command() throws Exception {
1101+
Method parseCommandMethod = App.class.getDeclaredMethod("parseCommand", String.class);
1102+
parseCommandMethod.setAccessible(true);
1103+
1104+
Command result = (Command) parseCommandMethod.invoke(null, "sbom");
1105+
assertThat(result).isEqualTo(Command.SBOM);
1106+
}
10161107
}

0 commit comments

Comments
 (0)