Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ public class TrustifyExample {
var result = componentWithLicense.get();
var report = result.report(); // standard AnalysisReport
var licenseSummary = result.licenseSummary(); // license compatibility summary (may be null)

// generate a CycloneDX SBOM locally (no backend call required)
String sbomJson = exhortApi.generateSbom("/path/to/pom.xml");
}
}
```
Expand Down Expand Up @@ -670,6 +673,16 @@ java -jar trustify-da-java-client-cli.jar license <file_path>
```
Display project license information from manifest and LICENSE file in JSON format.

**SBOM Generation**
```shell
java -jar trustify-da-java-client-cli.jar sbom <file_path> [--output <path>]
```
Generate a CycloneDX SBOM from the specified manifest file locally, without sending anything to the backend.

Options:
- `--output <path>` - Write SBOM JSON to the specified file
- (default) - Print SBOM JSON to stdout

**Image Analysis**
```shell
java -jar trustify-da-java-client-cli.jar image <image_ref> [<image_ref>...] [--summary|--html]
Expand All @@ -690,9 +703,9 @@ Options:

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

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

The application will fail to start if this environment variable is not set.
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.

#### Examples

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

# SBOM generation (no backend required)
java -jar trustify-da-java-client-cli.jar sbom /path/to/pom.xml
java -jar trustify-da-java-client-cli.jar sbom /path/to/package.json --output sbom.json

# License information
java -jar trustify-da-java-client-cli.jar license /path/to/pom.xml

Expand Down
9 changes: 9 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/Api.java
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,13 @@ CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(Set<ImageRef> ima
throws IOException;

CompletableFuture<byte[]> imageAnalysisHtml(Set<ImageRef> imageRefs) throws IOException;

/**
* Generate a CycloneDX SBOM from a manifest file locally without sending anything to the backend.
*
* @param manifestFile the path for the manifest file
* @return the CycloneDX JSON SBOM content as a String
* @throws IOException when failed to load the manifest file
Comment thread
soul2zimate marked this conversation as resolved.
*/
String generateSbom(String manifestFile) throws IOException;
}
38 changes: 37 additions & 1 deletion src/main/java/io/github/guacsec/trustifyda/cli/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ private static CliArgs parseArgs(String[] args) {
return switch (command) {
case STACK, COMPONENT, LICENSE -> parseFileBasedArgs(command, args);
case IMAGE -> parseImageBasedArgs(command, args);
case SBOM -> parseSbomArgs(args);
};
}

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

private static CliArgs parseSbomArgs(String[] args) {
if (args.length < 2) {
throw new IllegalArgumentException("Missing required file path for sbom command");
}

Path path = validateFile(args[1]);
Path outputPath = null;

for (int i = 2; i < args.length; i++) {
if ("--output".equals(args[i])) {
if (i + 1 >= args.length) {
throw new IllegalArgumentException("Missing value for --output flag");
}
outputPath = Paths.get(args[++i]);
} else {
throw new IllegalArgumentException("Unknown option for sbom command: " + args[i]);
}
}

return new CliArgs(Command.SBOM, path, outputPath);
}

private static Command parseCommand(String commandStr) {
return switch (commandStr) {
case "stack" -> Command.STACK;
case "component" -> Command.COMPONENT;
case "image" -> Command.IMAGE;
case "license" -> Command.LICENSE;
case "sbom" -> Command.SBOM;
default ->
throw new IllegalArgumentException(
"Unknown command: "
+ commandStr
+ ". Use 'stack', 'component', 'image', or 'license'");
+ ". Use 'stack', 'component', 'image', 'license', or 'sbom'");
};
}

Expand Down Expand Up @@ -202,6 +226,7 @@ private static CompletableFuture<String> executeCommand(CliArgs args) throws IOE
executeComponentAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat);
case IMAGE -> executeImageAnalysis(args.imageRefs, args.outputFormat);
case LICENSE -> executeLicenseCheck(args.filePath.toAbsolutePath());
case SBOM -> executeSbomGeneration(args);
};
}

Expand Down Expand Up @@ -300,6 +325,17 @@ private static CompletableFuture<String> executeImageAnalysis(
};
}

private static CompletableFuture<String> executeSbomGeneration(CliArgs args) throws IOException {
Api api = new ExhortApi();
String sbomJson = api.generateSbom(args.filePath.toAbsolutePath().toString());
if (args.outputPath != null) {
Files.writeString(args.outputPath, sbomJson);
return CompletableFuture.completedFuture(
"SBOM written to " + args.outputPath.toAbsolutePath());
}
return CompletableFuture.completedFuture(sbomJson);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

private static String formatImageAnalysisResult(Map<ImageRef, AnalysisReport> analysisResults) {
try {
return MAPPER.writeValueAsString(analysisResults);
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,29 @@ public class CliArgs {
public final Path filePath;
public final Set<ImageRef> imageRefs;
public final OutputFormat outputFormat;
public final Path outputPath;

public CliArgs(Command command, Path filePath, OutputFormat outputFormat) {
this.command = command;
this.filePath = filePath;
this.imageRefs = null;
this.outputFormat = outputFormat;
this.outputPath = null;
}

public CliArgs(Command command, Set<ImageRef> imageRefs, OutputFormat outputFormat) {
this.command = command;
this.filePath = null;
this.imageRefs = imageRefs;
this.outputFormat = outputFormat;
this.outputPath = null;
}

public CliArgs(Command command, Path filePath, Path outputPath) {
this.command = command;
this.filePath = filePath;
this.imageRefs = null;
this.outputFormat = null;
this.outputPath = outputPath;
}
}
3 changes: 2 additions & 1 deletion src/main/java/io/github/guacsec/trustifyda/cli/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ public enum Command {
STACK,
COMPONENT,
IMAGE,
LICENSE
LICENSE,
SBOM
}
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,14 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType
return buildRequest(content, uri, acceptType, "Stack Analysis");
}

@Override
public String generateSbom(final String manifestFile) throws IOException {
var manifestPath = Path.of(manifestFile);
var provider = Ecosystem.getProvider(manifestPath);
var content = provider.provideStack();
return new String(content.buffer, java.nio.charset.StandardCharsets.UTF_8);
}

@Override
public CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(
final Set<ImageRef> imageRefs) throws IOException {
Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/cli_help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ COMMANDS:
--summary Output summary in JSON format (without license check)
(default) Output full report in JSON format (includes license summary)

sbom <file_path> [--output <path>]
Generate a CycloneDX SBOM from the specified manifest file (no backend call)
Options:
--output <path> Write SBOM JSON to the specified file
(default) Print SBOM JSON to stdout

license <file_path>
Display project license information from manifest and LICENSE file in JSON format

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

# SBOM generation
java -jar trustify-da-java-client-cli.jar sbom /path/to/pom.xml
java -jar trustify-da-java-client-cli.jar sbom /path/to/package.json --output sbom.json

# License information
java -jar trustify-da-java-client-cli.jar license /path/to/pom.xml

Expand Down
93 changes: 92 additions & 1 deletion src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,13 @@ void command_enum_should_have_correct_values() {
assertThat(Command.COMPONENT).isNotNull();
assertThat(Command.IMAGE).isNotNull();
assertThat(Command.LICENSE).isNotNull();
assertThat(Command.values()).hasSize(4);
assertThat(Command.SBOM).isNotNull();
assertThat(Command.values()).hasSize(5);
assertThat(Command.valueOf("STACK")).isEqualTo(Command.STACK);
assertThat(Command.valueOf("COMPONENT")).isEqualTo(Command.COMPONENT);
assertThat(Command.valueOf("IMAGE")).isEqualTo(Command.IMAGE);
assertThat(Command.valueOf("LICENSE")).isEqualTo(Command.LICENSE);
assertThat(Command.valueOf("SBOM")).isEqualTo(Command.SBOM);
}

@Test
Expand Down Expand Up @@ -1013,4 +1015,93 @@ private AnalysisReport defaultAnalysisReport() {
.putSourcesItem("osv", new Source().summary(new SourceSummary())));
return report;
}

@Test
void executeCommand_with_sbom_should_return_sbom_json() throws Exception {
CliArgs args = new CliArgs(Command.SBOM, TEST_FILE, (Path) null);

var fakeSbom = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}";

try (MockedConstruction<ExhortApi> mockedExhortApi =
mockConstruction(
ExhortApi.class,
(mock, context) -> {
when(mock.generateSbom(any(String.class))).thenReturn(fakeSbom);
})) {

Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
executeCommandMethod.setAccessible(true);

CompletableFuture<String> result =
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);

assertThat(result).isNotNull();
assertThat(result.get()).isEqualTo(fakeSbom);
}
}

@Test
void executeCommand_with_sbom_and_output_should_write_to_file() throws Exception {
var outputFile = java.nio.file.Files.createTempFile("sbom_output_", ".json");
java.nio.file.Files.deleteIfExists(outputFile);

CliArgs args = new CliArgs(Command.SBOM, TEST_FILE, outputFile);

var fakeSbom = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}";

try (MockedConstruction<ExhortApi> mockedExhortApi =
mockConstruction(
ExhortApi.class,
(mock, context) -> {
when(mock.generateSbom(any(String.class))).thenReturn(fakeSbom);
})) {

Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
executeCommandMethod.setAccessible(true);

CompletableFuture<String> result =
(CompletableFuture<String>) executeCommandMethod.invoke(null, args);

assertThat(result).isNotNull();
assertThat(result.get()).contains("SBOM written to");
assertThat(java.nio.file.Files.readString(outputFile)).isEqualTo(fakeSbom);
} finally {
java.nio.file.Files.deleteIfExists(outputFile);
}
}

@Test
void executeCommand_with_sbom_and_unsupported_file_should_throw_exception() throws Exception {
var tmpFile = java.nio.file.Files.createTempFile("unsupported_", ".xyz");

CliArgs args = new CliArgs(Command.SBOM, tmpFile, (Path) null);

try (MockedConstruction<ExhortApi> mockedExhortApi =
mockConstruction(
ExhortApi.class,
(mock, context) -> {
when(mock.generateSbom(any(String.class)))
.thenThrow(new IllegalStateException("Unknown manifest file unsupported_.xyz"));
})) {

Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class);
executeCommandMethod.setAccessible(true);

assertThatThrownBy(() -> executeCommandMethod.invoke(null, args))
.cause()
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Unknown manifest file");
} finally {
java.nio.file.Files.deleteIfExists(tmpFile);
}
}

@Test
void parseCommand_with_sbom_should_return_sbom_command() throws Exception {
Method parseCommandMethod = App.class.getDeclaredMethod("parseCommand", String.class);
parseCommandMethod.setAccessible(true);

Command result = (Command) parseCommandMethod.invoke(null, "sbom");
assertThat(result).isEqualTo(Command.SBOM);
}
}
Loading
Loading