diff --git a/README.md b/README.md
index 4d6d7b0..059464a 100644
--- a/README.md
+++ b/README.md
@@ -415,6 +415,13 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
Exclusion patterns configured in **Manifest Exclusion Patterns** are applied during workspace discovery to skip
specific packages from the batch analysis.
+- **Generate SBOM**
+
You can generate a CycloneDX SBOM (Software Bill of Materials) from any supported manifest file.
+ Right-click on a manifest file in the editor or **Project** window, and click **Generate SBOM**.
+ A native Save File dialog will open with a default filename of `bom.json`. Choose a location to save the SBOM.
+ The generated SBOM is in CycloneDX JSON format and contains the full dependency tree of your project.
+ This operation is performed locally and does not send any data to the backend.
+
- **Red Hat Dependency Analytics report**
The Red Hat Dependency Analytics report is a temporary HTML file that exist if the **Red Hat Dependency Analytics
Report** tab remains open.
diff --git a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
index 30f0921..a0e47f6 100644
--- a/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
+++ b/src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
@@ -151,6 +151,25 @@ public ComponentAnalysisResult getComponentAnalysis(final String packageManager,
return null;
}
+ public String generateSbom(final String packageManager, final String manifestName, final String manifestPath) {
+ var telemetryMsg = TelemetryService.instance().action("sbom-generation");
+ telemetryMsg.property(TelemetryKeys.ECOSYSTEM.toString(), packageManager);
+ telemetryMsg.property(TelemetryKeys.PLATFORM.toString(), System.getProperty("os.name"));
+ telemetryMsg.property(TelemetryKeys.MANIFEST.toString(), manifestName);
+ telemetryMsg.property(TelemetryKeys.TRUST_DA_TOKEN.toString(), ApiSettingsState.getInstance().rhdaToken);
+
+ try {
+ setRequestProperties(manifestName);
+ String sbomJson = exhortApi.generateSbom(manifestPath);
+ telemetryMsg.send();
+ return sbomJson;
+ } catch (IOException exc) {
+ telemetryMsg.error(exc);
+ telemetryMsg.send();
+ throw new RuntimeException(exc);
+ }
+ }
+
/**
* Sets up common request properties shared across all analysis types (stack, batch, image).
* Configures the plugin descriptor, backend connection, all tool paths, and proxy settings.
diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java
new file mode 100644
index 0000000..04b2c6d
--- /dev/null
+++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/GenerateSbomAction.java
@@ -0,0 +1,127 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+package org.jboss.tools.intellij.stackanalysis;
+
+import com.intellij.openapi.actionSystem.ActionUpdateThread;
+import com.intellij.openapi.actionSystem.AnAction;
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.actionSystem.CommonDataKeys;
+import com.intellij.openapi.actionSystem.PlatformDataKeys;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.openapi.fileChooser.FileChooserFactory;
+import com.intellij.openapi.fileChooser.FileSaverDescriptor;
+import com.intellij.openapi.fileChooser.FileSaverDialog;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.ui.Messages;
+import com.intellij.openapi.vfs.LocalFileSystem;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.openapi.vfs.VirtualFileWrapper;
+import com.intellij.psi.PsiFile;
+import org.jetbrains.annotations.NotNull;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.intellij.notification.NotificationGroupManager;
+import com.intellij.notification.NotificationType;
+import org.jboss.tools.intellij.componentanalysis.CAAnnotator;
+import org.jboss.tools.intellij.exhort.ApiService;
+
+public class GenerateSbomAction extends AnAction {
+ private static final Logger logger = Logger.getInstance(GenerateSbomAction.class);
+
+ @Override
+ public @NotNull ActionUpdateThread getActionUpdateThread() {
+ return ActionUpdateThread.BGT;
+ }
+
+ @Override
+ public void actionPerformed(@NotNull AnActionEvent event) {
+ Project project = event.getProject();
+ if (project == null) {
+ return;
+ }
+
+ VirtualFile manifestFile = event.getData(PlatformDataKeys.VIRTUAL_FILE);
+ if (manifestFile == null) {
+ return;
+ }
+
+ FileSaverDescriptor descriptor = new FileSaverDescriptor(
+ "Save SBOM",
+ "Choose location to save the CycloneDX SBOM",
+ "json"
+ );
+ FileSaverDialog dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project);
+
+ VirtualFile baseDir = LocalFileSystem.getInstance().findFileByPath(
+ manifestFile.getParent().getPath()
+ );
+ VirtualFileWrapper fileWrapper = dialog.save(baseDir, "bom.json");
+ if (fileWrapper == null) {
+ return;
+ }
+
+ Path savePath = fileWrapper.getFile().toPath();
+
+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
+ try {
+ ApiService apiService = ApplicationManager.getApplication().getService(ApiService.class);
+ String sbomJson = apiService.generateSbom(
+ CAAnnotator.getPackageManager(manifestFile.getName()),
+ manifestFile.getName(),
+ manifestFile.getPath()
+ );
+
+ Files.writeString(savePath, sbomJson, StandardCharsets.UTF_8);
+
+ ApplicationManager.getApplication().invokeLater(() -> {
+ if (project.isDisposed()) {
+ return;
+ }
+ NotificationGroupManager.getInstance()
+ .getNotificationGroup("Red Hat Dependency Analytics")
+ .createNotification(
+ "SBOM saved to " + savePath,
+ NotificationType.INFORMATION
+ )
+ .notify(project);
+ });
+ } catch (Exception ex) {
+ logger.error(ex);
+ ApplicationManager.getApplication().invokeLater(() -> {
+ if (project.isDisposed()) {
+ return;
+ }
+ Messages.showErrorDialog(
+ project,
+ "SBOM generation failed: " + ex.getLocalizedMessage(),
+ "Error"
+ );
+ });
+ }
+ });
+ }
+
+ @Override
+ public void update(AnActionEvent event) {
+ PsiFile psiFile = event.getData(CommonDataKeys.PSI_FILE);
+ if (psiFile != null) {
+ event.getPresentation().setEnabledAndVisible(
+ SaUtils.SUPPORTED_MANIFEST_FILES.contains(psiFile.getName())
+ );
+ } else {
+ event.getPresentation().setEnabledAndVisible(false);
+ }
+ }
+}
diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java
index 481a7c8..c342938 100644
--- a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java
+++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaAction.java
@@ -28,23 +28,9 @@
import org.jboss.tools.intellij.report.AnalyticsReportUtils;
import org.jetbrains.annotations.NotNull;
-import java.util.Arrays;
-import java.util.List;
-
public class SaAction extends AnAction {
private static final Logger logger = Logger.getInstance(SaAction.class);
- private static final List supportedManifestFiles = Arrays.asList(
- "pom.xml",
- "package.json",
- "go.mod",
- "requirements.txt",
- "build.gradle",
- "build.gradle.kts",
- "Cargo.toml",
- "pyproject.toml"
- );
-
public SaAction() {
}
@@ -123,7 +109,7 @@ public void update(AnActionEvent event) {
// Check if file where context menu is opened is type of supported extension.
// If yes then show the action for SA in menu
if (psiFile != null) {
- event.getPresentation().setEnabledAndVisible(supportedManifestFiles
+ event.getPresentation().setEnabledAndVisible(SaUtils.SUPPORTED_MANIFEST_FILES
.contains(psiFile.getName()));
} else {
event.getPresentation().setEnabledAndVisible(false);
diff --git a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
index 9bfcc44..40e5d6f 100644
--- a/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
+++ b/src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
@@ -16,8 +16,21 @@
import com.intellij.openapi.vfs.VirtualFile;
import org.jboss.tools.intellij.exhort.ApiService;
+import java.util.List;
+
public class SaUtils {
+ public static final List SUPPORTED_MANIFEST_FILES = List.of(
+ "pom.xml",
+ "package.json",
+ "go.mod",
+ "requirements.txt",
+ "build.gradle",
+ "build.gradle.kts",
+ "Cargo.toml",
+ "pyproject.toml"
+ );
+
public JsonObject performSA(VirtualFile manifestFile) {
// Get SA report for given manifest file.
String reportLink;
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index e73c1f9..8df7069 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -616,6 +616,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java
index ce1bdaa..aaa8b92 100644
--- a/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java
+++ b/src/test/java/org/jboss/tools/intellij/componentanalysis/pypi/PyprojectCAAnnotatorTest.java
@@ -413,20 +413,11 @@ public void testPep621IgnoreComment() {
// ── SaAction / SaUtils recognition tests ────────────────────────────────────
- /** Verifies that SaAction.supportedManifestFiles contains pyproject.toml. */
+ /** Verifies that SUPPORTED_MANIFEST_FILES contains pyproject.toml. */
@Test
- public void testSaActionRecognizesPyprojectToml() throws Exception {
- // Given the SaAction class
- Field field = org.jboss.tools.intellij.stackanalysis.SaAction.class
- .getDeclaredField("supportedManifestFiles");
- field.setAccessible(true);
-
- // When reading the supported manifest files list
- @SuppressWarnings("unchecked")
- List manifests = (List) field.get(null);
-
- // Then pyproject.toml should be included
- assertTrue("SaAction should recognize pyproject.toml", manifests.contains("pyproject.toml"));
+ public void testSaActionRecognizesPyprojectToml() {
+ assertTrue("SUPPORTED_MANIFEST_FILES should contain pyproject.toml",
+ org.jboss.tools.intellij.stackanalysis.SaUtils.SUPPORTED_MANIFEST_FILES.contains("pyproject.toml"));
}
/** Verifies that SaUtils.determinePackageManagerName maps pyproject.toml to python. */
diff --git a/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java b/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java
new file mode 100644
index 0000000..fd2912a
--- /dev/null
+++ b/src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java
@@ -0,0 +1,48 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Red Hat, Inc.
+ * Distributed under license by Red Hat, Inc. All rights reserved.
+ * This program is made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v20.html
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ ******************************************************************************/
+
+package org.jboss.tools.intellij.exhort;
+
+import io.github.guacsec.trustifyda.Api;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+/**
+ * Tests for ApiService that don't require IntelliJ platform initialization.
+ * The generateSbom method depends on TelemetryService and ApiSettingsState
+ * which need the platform, so we verify the API contract at the interface level.
+ */
+public class ApiServiceTest {
+
+ @Test
+ public void apiInterfaceHasGenerateSbomMethod() throws NoSuchMethodException {
+ Method method = Api.class.getMethod("generateSbom", String.class);
+ assertNotNull("Api interface should have generateSbom(String) method", method);
+ assertEquals("generateSbom should return String", String.class, method.getReturnType());
+ }
+
+ @Test
+ public void apiServiceHasGenerateSbomMethod() throws NoSuchMethodException {
+ Method method = ApiService.class.getMethod("generateSbom", String.class, String.class, String.class);
+ assertNotNull("ApiService should have generateSbom(String, String, String) method", method);
+ assertEquals("generateSbom should return String", String.class, method.getReturnType());
+ }
+
+ @Test
+ public void apiServiceAcceptsApiInConstructor() throws NoSuchMethodException {
+ // Verify the package-private constructor used for testing exists
+ ApiService.class.getDeclaredConstructor(Api.class);
+ }
+}