Skip to content

Commit 0ce390e

Browse files
soul2zimateclaude
andauthored
feat(api): add generateSbom API method and SBOM CLI command (#396)
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 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 90c80d6 commit 0ce390e

9 files changed

Lines changed: 297 additions & 16 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,14 @@ 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+
* @throws IllegalStateException when the manifest file type is not supported
135+
*/
136+
String generateSbom(String manifestFile) throws IOException;
127137
}

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: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,13 @@ public final class ExhortApi implements Api {
8383
public static final String S_API_V5_LICENSES_IDENTIFY = "%s/api/v5/licenses/identify";
8484
private static final String TRUSTIFY_DA_LICENSE_CHECK = "TRUSTIFY_DA_LICENSE_CHECK";
8585

86-
private final String endpoint;
86+
private String endpoint;
8787

8888
public String getEndpoint() {
89-
return endpoint;
89+
if (this.endpoint == null) {
90+
this.endpoint = getExhortUrl();
91+
}
92+
return this.endpoint;
9093
}
9194

9295
private final HttpClient client;
@@ -117,7 +120,6 @@ static HttpClient.Version getHttpVersion() {
117120
commonHookBeginning(true);
118121
this.client = client;
119122
this.mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
120-
this.endpoint = getExhortUrl();
121123
}
122124

123125
public static HttpClient createHttpClient() {
@@ -346,7 +348,7 @@ public CompletableFuture<AnalysisReport> componentAnalysis(
346348
String exClientTraceId = commonHookBeginning(false);
347349
var manifestPath = Path.of(manifest);
348350
var provider = Ecosystem.getProvider(manifestPath);
349-
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint));
351+
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
350352
var content = provider.provideComponent();
351353
commonHookAfterProviderCreatedSbomAndBeforeExhort();
352354
return getAnalysisReportForComponent(uri, content, exClientTraceId);
@@ -390,7 +392,7 @@ public CompletableFuture<AnalysisReport> componentAnalysis(String manifestFile)
390392
String exClientTraceId = commonHookBeginning(false);
391393
var manifestPath = Path.of(manifestFile);
392394
var provider = Ecosystem.getProvider(manifestPath);
393-
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint));
395+
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
394396
var content = provider.provideComponent();
395397
commonHookAfterProviderCreatedSbomAndBeforeExhort();
396398
return getAnalysisReportForComponent(uri, content, exClientTraceId);
@@ -431,13 +433,21 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType
431433
throws IOException {
432434
var manifestPath = Path.of(manifestFile);
433435
var provider = Ecosystem.getProvider(manifestPath);
434-
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint));
436+
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
435437
var content = provider.provideStack();
436438
commonHookAfterProviderCreatedSbomAndBeforeExhort();
437439

438440
return buildRequest(content, uri, acceptType, "Stack Analysis");
439441
}
440442

443+
@Override
444+
public String generateSbom(final String manifestFile) throws IOException {
445+
var manifestPath = Path.of(manifestFile);
446+
var provider = Ecosystem.getProvider(manifestPath);
447+
var content = provider.provideStack();
448+
return new String(content.buffer, java.nio.charset.StandardCharsets.UTF_8);
449+
}
450+
441451
@Override
442452
public CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(
443453
final Set<ImageRef> imageRefs) throws IOException {
@@ -510,7 +520,7 @@ <H, T> CompletableFuture<T> performBatchAnalysis(
510520
final String analysisName)
511521
throws IOException {
512522
String exClientTraceId = commonHookBeginning(false);
513-
var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, this.endpoint));
523+
var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, getEndpoint()));
514524
var sboms = sbomsGenerator.get();
515525
var content =
516526
new Provider.Content(
@@ -572,7 +582,7 @@ public CompletableFuture<ComponentAnalysisResult> componentAnalysisWithLicense(
572582
String exClientTraceId = commonHookBeginning(false);
573583
var manifestPath = Path.of(manifestFile);
574584
var provider = Ecosystem.getProvider(manifestPath);
575-
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint));
585+
var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint()));
576586
var content = provider.provideComponent();
577587
String sbomJson = new String(content.buffer);
578588
commonHookAfterProviderCreatedSbomAndBeforeExhort();
@@ -603,7 +613,7 @@ public CompletableFuture<ComponentAnalysisResult> componentAnalysisWithLicense(
603613
*/
604614
public CompletableFuture<JsonNode> getLicenseDetails(String spdxId) {
605615
String encodedId = URLEncoder.encode(spdxId, StandardCharsets.UTF_8).replace("+", "%20");
606-
URI uri = URI.create(String.format(S_API_V5_LICENSES, this.endpoint, encodedId));
616+
URI uri = URI.create(String.format(S_API_V5_LICENSES, getEndpoint(), encodedId));
607617
HttpRequest request = buildGetRequest(uri, "License Details");
608618

609619
return this.client
@@ -649,7 +659,7 @@ public CompletableFuture<String> identifyLicense(Path licenseFilePath) {
649659
LOG.warning(String.format("Failed to read license file: %s", e.getMessage()));
650660
return CompletableFuture.completedFuture(null);
651661
}
652-
URI uri = URI.create(String.format(S_API_V5_LICENSES_IDENTIFY, this.endpoint));
662+
URI uri = URI.create(String.format(S_API_V5_LICENSES_IDENTIFY, getEndpoint()));
653663
HttpRequest request =
654664
buildPostRequest(
655665
uri,

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

0 commit comments

Comments
 (0)