Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,13 @@ When modifying the grammar or lexer files, you need to regenerate the parser cla
<br >Exclusion patterns configured in **Manifest Exclusion Patterns** are applied during workspace discovery to skip
specific packages from the batch analysis.

- **Generate SBOM**
<br >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**
<br >The Red Hat Dependency Analytics report is a temporary HTML file that exist if the **Red Hat Dependency Analytics
Report** tab remains open.
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/org/jboss/tools/intellij/exhort/ApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Comment thread
ruromero marked this conversation as resolved.
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> supportedManifestFiles = Arrays.asList(
"pom.xml",
"package.json",
"go.mod",
"requirements.txt",
"build.gradle",
"build.gradle.kts",
"Cargo.toml",
"pyproject.toml"
);

public SaAction() {

}
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/jboss/tools/intellij/stackanalysis/SaUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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;
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,18 @@

<separator/>

<add-to-group group-id="EditorPopupMenu" anchor="first"/>
<add-to-group group-id="NavBarToolBar" anchor="first"/>
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
</group>

<group id="generateSbom-group">
<action id="generateSbom" text="Generate SBOM"
class="org.jboss.tools.intellij.stackanalysis.GenerateSbomAction"
icon="/images/report-icon.png"/>
<separator/>

<add-to-group group-id="EditorPopupMenu" anchor="first"/>
<add-to-group group-id="NavBarToolBar" anchor="first"/>
<add-to-group group-id="ProjectViewPopupMenu" anchor="first"/>
</group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> manifests = (List<String>) 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. */
Expand Down
48 changes: 48 additions & 0 deletions src/test/java/org/jboss/tools/intellij/exhort/ApiServiceTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading