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); + } +}