diff --git a/README.md b/README.md index 9c8245ac..88bed500 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,15 @@ This plugin provides easy integration with JetBrains and is compatible with all - AI Secure Coding Assistant (ASCA) - A lightweight scan engine that runs in the background while you work, enabling developers to identify and remediate secure coding best practice violations **as they code**. +## Checkmarx One Developer Assist – AI guided remediation +- An advanced security agent that delivers real-time context-aware prevention, remediation, and guidance to developers from the IDE. +- OSS Realtime scanner identifies risks in open source packages used in your project. +- MCP-based agentic AI remediation. +- AI powered explanation of risk details + + **COMING SOON** - additional realtime scanners for identifying risks in container images, as well as exposed secrets and IaC risks. + + ## Prerequisites - You are running IntelliJ version 2022.2+ or another JetBrains IDE that is based on a supported version of IntelliJ. @@ -93,6 +102,11 @@ This plugin provides easy integration with JetBrains and is compatible with all > - CxOne role `view-policy-management` > - IAM role `default-roles` +To use **Dev Assist**, you need the following additional prerequisites: +- A Checkmarx One account with a Checkmarx One Assist license +- The Checkmarx MCP must be activated for your tenant account in the Checkmarx One UI under Settings → Plugins. This must be done by an account admin. +- You must have GitHub Copilot Chat (AI Agent) installed + ## Initial Setup - Verify that all prerequisites are in place. @@ -100,13 +114,13 @@ This plugin provides easy integration with JetBrains and is compatible with all - Install the **Checkmarx One** plugin and configure the settings as described [here](https://docs.checkmarx.com/en/34965-68734-installing-and-setting-up-the-checkmarx-one-jetbrains-pluging-68734.html#UUID-8d3bdd51-782c-2816-65e2-38d7529651c8_section-idm449017032697283334758018635). +**Note:** To use Dev Assist, you need to Start the Checkmarx MCP server. ## Usage To see how you can use our tool, please refer to the [Documentation](https://docs.checkmarx.com/en/34965-68734-installing-and-setting-up-the-checkmarx-one-jetbrains-pluging.html) - ## Feedback We’d love to hear your feedback! If you come across a bug or have a feature request, please let us know by submitting an issue in [GitHub Issues](https://github.com/Checkmarx/ast-jetbrains-plugin/issues). diff --git a/src/main/java/com/checkmarx/intellij/Constants.java b/src/main/java/com/checkmarx/intellij/Constants.java index 777df232..12c6ba2e 100644 --- a/src/main/java/com/checkmarx/intellij/Constants.java +++ b/src/main/java/com/checkmarx/intellij/Constants.java @@ -2,12 +2,15 @@ import org.jetbrains.annotations.NonNls; +import java.util.List; + /** * Non-translatable constants. */ @NonNls public final class Constants { + private Constants() { // forbid instantiation of the class } @@ -15,6 +18,7 @@ private Constants() { public static final String BUNDLE_PATH = "messages.CxBundle"; public static final String LOGGER_CAT_PREFIX = "CX#"; + public static final String CXONE_ASSIST = "CxOne Assist"; public static final String GLOBAL_SETTINGS_ID = "settings.ast"; public static final String TOOL_WINDOW_ID = "Checkmarx"; @@ -78,10 +82,14 @@ private Constants() { public static final String SCAN_STATUS_RUNNING = "running"; public static final String SCAN_STATUS_COMPLETED = "completed"; public static final String JET_BRAINS_AGENT_NAME = "Jetbrains"; - public static final String ASCA_CRITICAL_SEVERITY = "Critical"; - public static final String ASCA_HIGH_SEVERITY = "High"; - public static final String ASCA_MEDIUM_SEVERITY = "Medium"; - public static final String ASCA_LOW_SEVERITY = "Low"; + + public static final String MALICIOUS_SEVERITY = "Malicious"; + public static final String CRITICAL_SEVERITY = "Critical"; + public static final String HIGH_SEVERITY = "High"; + public static final String MEDIUM_SEVERITY = "Medium"; + public static final String LOW_SEVERITY = "Low"; + public static final String OK = "OK"; + public static final String UNKNOWN = "Unknown"; public static final String IGNORE_LABEL = "IGNORED"; public static final String NOT_IGNORE_LABEL = "NOT_IGNORED"; @@ -97,7 +105,11 @@ private Constants() { /** * Inner static final class, to maintain the constants used in authentication. */ - public static final class AuthConstants{ + public static final class AuthConstants { + + private AuthConstants() { + throw new UnsupportedOperationException("Cannot instantiate AuthConstants class"); + } public static final String OAUTH_IDE_CLIENT_ID = "ide-integration"; public static final String ALGO_SHA256 = "SHA-256"; @@ -114,5 +126,76 @@ public static final class AuthConstants{ public static final int TIME_OUT_SECONDS = 120; } + /** + * The RealTimeConstants class defines a collection of constant values + * related to real-time scanning functionalities, including support for + * different scanning engines and associated configurations. + */ + public static final class RealTimeConstants { + + private RealTimeConstants() { + throw new UnsupportedOperationException("Cannot instantiate RealTimeConstants class"); + } + + // Tab Name Constants + public static final String DEVASSIST_TAB = "CxOne Assist Findings"; + + // OSS Scanner Constants + public static final String ACTIVATE_OSS_REALTIME_SCANNER = "Activate OSS-Realtime"; + public static final String OSS_REALTIME_SCANNER = "Checkmarx Open Source Realtime Scanner (OSS-Realtime)"; + public static final String OSS_REALTIME_SCANNER_START = "Realtime OSS Scanner Engine started"; + public static final String OSS_REALTIME_SCANNER_DISABLED = "Realtime OSS Scanner Engine disabled"; + public static final String OSS_REALTIME_SCANNER_DIRECTORY = "Cx-oss-realtime-scanner"; + public static final String ERROR_OSS_REALTIME_SCANNER = "Failed to handle OSS Realtime scan"; + + //Dev Assist Fixes Constants + public static final String FIX_WITH_CXONE_ASSIST = "Copy fix prompt"; + public static final String VIEW_DETAILS_FIX_NAME = "View details"; + public static final String IGNORE_THIS_VULNERABILITY_FIX_NAME = "Ignore this vulnerability"; + public static final String IGNORE_ALL_OF_THIS_TYPE_FIX_NAME = "Ignore all of this type"; + + public static final List MANIFEST_FILE_PATTERNS = List.of( + "**/Directory.Packages.props", + "**/packages.config", + "**/pom.xml", + "**/package.json", + "**/requirements.txt", + "**/go.mod", + "**/*.csproj" + ); + //Tooltip description constants + public static final String RISK_PACKAGE = "risk package"; + public static final String SEVERITY_PACKAGE = "Severity Package"; + public static final String PACKAGE_DETECTED = "package detected"; + public static final String THEME = "THEME"; + // Dev Assist Remediation + public static final String CX_AGENT_NAME = "Checkmarx One Assist"; + // Files generated by the agent (Copilot) + public static final List AGENT_DUMMY_FILES = List.of("/Dummy.txt", "/"); + } + + /** + * Constant class to hold image paths. + */ + public static final class ImagePaths { + + private ImagePaths() { + throw new UnsupportedOperationException("Cannot instantiate ImagePaths class"); + } + + public static final String DEV_ASSIST_PNG = "/icons/devassist/tooltip/cxone_assist"; + public static final String CRITICAL_PNG = "/icons/devassist/tooltip/critical"; + public static final String HIGH_PNG = "/icons/devassist/tooltip/high"; + public static final String MEDIUM_PNG = "/icons/devassist/tooltip/medium"; + public static final String LOW_PNG = "/icons/devassist/tooltip/low"; + public static final String MALICIOUS_PNG = "/icons/devassist/tooltip/malicious"; + public static final String PACKAGE_PNG = "/icons/devassist/tooltip/package"; + + // Vulnerability Severity Count Icons + public static final String CRITICAL_16_PNG = "/icons/devassist/tooltip/severity_count/critical"; + public static final String HIGH_16_PNG = "/icons/devassist/tooltip/severity_count/high"; + public static final String MEDIUM_16_PNG = "/icons/devassist/tooltip/severity_count/medium"; + public static final String LOW_16_PNG = "/icons/devassist/tooltip/severity_count/low"; + } } diff --git a/src/main/java/com/checkmarx/intellij/CxIcons.java b/src/main/java/com/checkmarx/intellij/CxIcons.java index d4862b1e..3c6eb968 100644 --- a/src/main/java/com/checkmarx/intellij/CxIcons.java +++ b/src/main/java/com/checkmarx/intellij/CxIcons.java @@ -12,15 +12,76 @@ public final class CxIcons { private CxIcons() { } - public static final Icon CHECKMARX_13 = IconLoader.getIcon("/icons/checkmarx-mono-13.png", CxIcons.class); + public static final Icon CHECKMARX_13 = IconLoader.getIcon("/icons/checkmarx-plugin-13.png", CxIcons.class); public static final Icon CHECKMARX_13_COLOR = IconLoader.getIcon("/icons/checkmarx-13.png", CxIcons.class); public static final Icon CHECKMARX_80 = IconLoader.getIcon("/icons/checkmarx-80.png", CxIcons.class); - public static final Icon CRITICAL = IconLoader.getIcon("/icons/critical.svg", CxIcons.class); - public static final Icon HIGH = IconLoader.getIcon("/icons/high.svg", CxIcons.class); - public static final Icon MEDIUM = IconLoader.getIcon("/icons/medium.svg", CxIcons.class); - public static final Icon LOW = IconLoader.getIcon("/icons/low.svg", CxIcons.class); - public static final Icon INFO = IconLoader.getIcon("/icons/info.svg", CxIcons.class); public static final Icon COMMENT = IconLoader.getIcon("/icons/comment.svg", CxIcons.class); public static final Icon STATE = IconLoader.getIcon("/icons/Flags.svg", CxIcons.class); public static final Icon ABOUT = IconLoader.getIcon("/icons/about.svg", CxIcons.class); + public static final Icon INFO = IconLoader.getIcon("/icons/info.svg", CxIcons.class); + + public static Icon getWelcomeScannerIcon() { + return IconLoader.getIcon("/icons/welcomePageScanner.svg", CxIcons.class); + } + + public static Icon getWelcomeMcpDisableIcon() { + return IconLoader.getIcon("/icons/cxAIError.svg", CxIcons.class); + } + + public static final Icon STAR_ACTION = IconLoader.getIcon("/icons/devassist/star-action.svg", CxIcons.class); + + /** + * Inner static final class, to maintain the constants used in icons for the value 24*24. + */ + public static final class Regular { + + private Regular() { + } + + public static final Icon MALICIOUS = IconLoader.getIcon("/icons/devassist/severity_24/malicious.svg", CxIcons.class); + public static final Icon CRITICAL = IconLoader.getIcon("/icons/devassist/severity_24/critical.svg", CxIcons.class); + public static final Icon HIGH = IconLoader.getIcon("/icons/devassist/severity_24/high.svg", CxIcons.class); + public static final Icon MEDIUM = IconLoader.getIcon("/icons/devassist/severity_24/medium.svg", CxIcons.class); + public static final Icon LOW = IconLoader.getIcon("/icons/devassist/severity_24/low.svg", CxIcons.class); + public static final Icon IGNORED = IconLoader.getIcon("/icons/devassist/severity_24/ignored.svg", CxIcons.class); + public static final Icon OK = IconLoader.getIcon("/icons/devassist/severity_24/ok.svg", CxIcons.class); + + } + + /** + * Inner static final class, to maintain the constants used in icons for the value 20*20. + */ + public static final class Medium { + + private Medium() { + } + + public static final Icon MALICIOUS = IconLoader.getIcon("/icons/devassist/severity_20/malicious.svg", CxIcons.class); + public static final Icon CRITICAL = IconLoader.getIcon("/icons/devassist/severity_20/critical.svg", CxIcons.class); + public static final Icon HIGH = IconLoader.getIcon("/icons/devassist/severity_20/high.svg", CxIcons.class); + public static final Icon MEDIUM = IconLoader.getIcon("/icons/devassist/severity_20/medium.svg", CxIcons.class); + public static final Icon LOW = IconLoader.getIcon("/icons/devassist/severity_20/low.svg", CxIcons.class); + public static final Icon IGNORED = IconLoader.getIcon("/icons/devassist/severity_20/ignored.svg", CxIcons.class); + public static final Icon OK = IconLoader.getIcon("/icons/devassist/severity_20/ok.svg", CxIcons.class); + + } + + /** + * Inner static final class, to maintain the constants used in icons for the value 16*16. + */ + public static final class Small { + + private Small() { + } + + public static final Icon MALICIOUS = IconLoader.getIcon("/icons/devassist/severity_16/malicious.svg", CxIcons.class); + public static final Icon CRITICAL = IconLoader.getIcon("/icons/devassist/severity_16/critical.svg", CxIcons.class); + public static final Icon HIGH = IconLoader.getIcon("/icons/devassist/severity_16/high.svg", CxIcons.class); + public static final Icon MEDIUM = IconLoader.getIcon("/icons/devassist/severity_16/medium.svg", CxIcons.class); + public static final Icon LOW = IconLoader.getIcon("/icons/devassist/severity_16/low.svg", CxIcons.class); + public static final Icon IGNORED = IconLoader.getIcon("/icons/devassist/severity_16/ignored.svg", CxIcons.class); + public static final Icon OK = IconLoader.getIcon("/icons/devassist/severity_16/ok.svg", CxIcons.class); + public static final Icon UNKNOWN = IconLoader.getIcon("/icons/devassist/severity_16/unknown.svg", CxIcons.class); + + } } diff --git a/src/main/java/com/checkmarx/intellij/Resource.java b/src/main/java/com/checkmarx/intellij/Resource.java index 36535db6..1eeb5d75 100644 --- a/src/main/java/com/checkmarx/intellij/Resource.java +++ b/src/main/java/com/checkmarx/intellij/Resource.java @@ -112,5 +112,46 @@ public enum Resource { ERROR_SESSION_EXPIRED, SECRET_DETECTION, IAC_SECURITY, - NO_CHANGES + NO_CHANGES, + CXONE_ASSIST_TITLE, + OSS_REALTIME_TITLE, + OSS_REALTIME_CHECKBOX, + CXONE_ASSIST_LOGIN_MESSAGE, + CXONE_ASSIST_MCP_DISABLED_MESSAGE, + SECRETS_REALTIME_TITLE, + SECRETS_REALTIME_CHECKBOX, + CONTAINERS_REALTIME_TITLE, + CONTAINERS_REALTIME_CHECKBOX, + IAC_REALTIME_TITLE, + IAC_REALTIME_CHECKBOX, + CONTAINERS_TOOL_TITLE, + IAC_REALTIME_SCANNER_PREFIX, + GO_TO_CXONE_ASSIST_LINK, + WELCOME_TITLE, + WELCOME_SUBTITLE, + WELCOME_ASSIST_TITLE, + WELCOME_ASSIST_FEATURE_1, + WELCOME_ASSIST_FEATURE_2, + WELCOME_ASSIST_FEATURE_3, + WELCOME_MAIN_FEATURE_1, + WELCOME_MAIN_FEATURE_2, + WELCOME_MAIN_FEATURE_3, + WELCOME_MAIN_FEATURE_4, + WELCOME_CLOSE_BUTTON, + CONTAINERS_TOOL_DESCRIPTION, + MCP_SECTION_TITLE, + MCP_DESCRIPTION, + MCP_INSTALL_LINK, + MCP_EDIT_JSON_LINK, + WELCOME_MCP_INSTALLED_INFO, + MCP_NOTIFICATION_TITLE, + MCP_CONFIG_SAVED, + MCP_AUTH_REQUIRED, + MCP_CONFIG_UP_TO_DATE, + MCP_NOT_FOUND, + CHECKING_MCP_STATUS, + STARTING_CHECKMARX_OSS_SCAN, + FAILED_OSS_SCAN_INITIALIZATION, + DEV_ASSIST_COPY_FIX_PROMPT, + DEV_ASSIST_COPY_VIEW_DETAILS_PROMPT } diff --git a/src/main/java/com/checkmarx/intellij/Utils.java b/src/main/java/com/checkmarx/intellij/Utils.java index d30d4894..340d8e83 100644 --- a/src/main/java/com/checkmarx/intellij/Utils.java +++ b/src/main/java/com/checkmarx/intellij/Utils.java @@ -2,9 +2,13 @@ import com.checkmarx.ast.wrapper.CxException; import com.checkmarx.intellij.settings.SettingsListener; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; import com.intellij.dvcs.repo.Repository; import com.intellij.dvcs.repo.VcsRepositoryManager; -import com.intellij.notification.*; +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationAction; +import com.intellij.notification.NotificationGroupManager; +import com.intellij.notification.NotificationType; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; @@ -352,7 +356,7 @@ public static boolean isBlank(CharSequence cs) { if (strLen == 0) { return true; } else { - for(int i = 0; i < strLen; ++i) { + for (int i = 0; i < strLen; ++i) { if (!Character.isWhitespace(cs.charAt(i))) { return false; } @@ -361,4 +365,34 @@ public static boolean isBlank(CharSequence cs) { } } + /** + * Escape HTML special characters + * + * @param text String to escape + * @return Escaped string + */ + public static String escapeHtml(String text) { + if (Objects.isNull(text) || text.isBlank()) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + /** + * Check if the user is authenticated or not + * + * @return true if a user is authenticated otherwise false + */ + public static boolean isUserAuthenticated() { + try { + return GlobalSettingsState.getInstance().isAuthenticated(); + } catch (Exception e) { + LOGGER.error("Exception occurred while checking user authentication.", e.getMessage()); + return false; + } + } } \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/commands/TenantSetting.java b/src/main/java/com/checkmarx/intellij/commands/TenantSetting.java index a47f84c8..098e9b45 100644 --- a/src/main/java/com/checkmarx/intellij/commands/TenantSetting.java +++ b/src/main/java/com/checkmarx/intellij/commands/TenantSetting.java @@ -1,28 +1,43 @@ package com.checkmarx.intellij.commands; -import com.checkmarx.ast.wrapper.CxConfig; import com.checkmarx.ast.wrapper.CxException; import com.checkmarx.intellij.settings.global.CxWrapperFactory; -import org.jetbrains.annotations.NotNull; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.checkmarx.intellij.settings.global.GlobalSettingsSensitiveState; +import com.intellij.openapi.diagnostic.Logger; import java.io.IOException; -import java.net.URISyntaxException; /** * Handle tenant settings related operations with the wrapper */ public class TenantSetting { + private static final Logger LOG = Logger.getInstance(TenantSetting.class); + /** * Check if current tenant has permissions to scan from the IDE * * @return true if tenant has permissions to scan. false otherwise */ - @NotNull public static boolean isScanAllowed() throws IOException, CxException, InterruptedException { return CxWrapperFactory.build().ideScansEnabled(); } + + /** + * Check if AI MCP server is enabled for the current tenant + * + * @return true if AI MCP server is enabled, false otherwise + */ + public static boolean isAiMcpServerEnabled(GlobalSettingsState state, GlobalSettingsSensitiveState sensitiveState) throws + IOException, + CxException, + InterruptedException { + LOG.debug("Checking AI MCP server enabled flag using provided credentials"); + return CxWrapperFactory.build(state, sensitiveState).aiMcpServerEnabled(); + } + } diff --git a/src/main/java/com/checkmarx/intellij/components/CxLinkLabel.java b/src/main/java/com/checkmarx/intellij/components/CxLinkLabel.java index ab23f8a4..466d6a04 100644 --- a/src/main/java/com/checkmarx/intellij/components/CxLinkLabel.java +++ b/src/main/java/com/checkmarx/intellij/components/CxLinkLabel.java @@ -24,6 +24,7 @@ public class CxLinkLabel extends HyperlinkLabel { private static final Logger LOGGER = Utils.getLogger(CxLinkLabel.class); + private final Consumer onClickHandler; public CxLinkLabel(@NotNull Resource resource, Consumer onClick) { this(Bundle.message(resource), onClick); @@ -31,17 +32,35 @@ public CxLinkLabel(@NotNull Resource resource, Consumer onClick) { public CxLinkLabel(@NotNull String text, Consumer onClick) { super(text); + this.onClickHandler = onClick; addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { super.mouseClicked(e); - onClick.accept(e); + if (isEnabled()) { + onClick.accept(e); + } } }); } + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + + if (enabled) { + // Restore normal link appearance + setForeground(null); // Use default link color + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + } else { + // Set disabled appearance + setForeground(Color.GRAY); + setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); + } + } + /** * Build label for documentation link. * Changes to underlined link with hand cursor when hovered. diff --git a/src/main/java/com/checkmarx/intellij/devassist/basescanner/BaseScannerCommand.java b/src/main/java/com/checkmarx/intellij/devassist/basescanner/BaseScannerCommand.java new file mode 100644 index 00000000..17ecf3f7 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/basescanner/BaseScannerCommand.java @@ -0,0 +1,114 @@ +package com.checkmarx.intellij.devassist.basescanner; + +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.configuration.GlobalScannerController; +import com.checkmarx.intellij.devassist.configuration.ScannerConfig; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * BaseScannerCommand is an abstract implementation of the ScannerCommand interface + * that provides foundational functionality for registering, deregistering, and + * managing a scanner's lifecycle for a given project. This class serves as a + * base implementation for custom scanner commands. + */ +public class BaseScannerCommand implements ScannerCommand { + private static final Logger LOGGER = Utils.getLogger(BaseScannerCommand.class); + public ScannerConfig config; + + public BaseScannerCommand(@NotNull Disposable parentDisposable, ScannerConfig config) { + Disposer.register(parentDisposable, this); + this.config = config; + } + + + /** + * Registers the project for the scanner which is invoked + * + * @param project - the project for the registration + */ + + @Override + public void register(Project project) { + boolean isActive = getScannerActivationStatus(); + if (!isActive) { + return; + } + if (isScannerRegisteredAlready(project)) { + return; + } + DevAssistUtils.globalScannerController().markRegistered(project, getScannerType()); + LOGGER.info(config.getEnabledMessage() + ":" + project.getName()); + initializeScanner(); + } + + /** + * De-registers the project for the scanner , + * This method is called in two cases, either project is closed by the user, or scanner is disabled + * + * @param project - the project that is registered + */ + + public void deregister(Project project) { + if (!DevAssistUtils.globalScannerController().isRegistered(project, getScannerType())) { + return; + } + DevAssistUtils.globalScannerController().markUnregistered(project, getScannerType()); + LOGGER.info(config.getDisabledMessage() + ":" + project.getName()); + if (project.isDisposed()) { + return; + } + ProblemHolderService.getInstance(project) + .removeAllProblemsOfType(getScannerType().toString()); + } + + + /** + * Returns the scanner activationStatus of the scanner engine + */ + private boolean getScannerActivationStatus() { + return DevAssistUtils.isScannerActive(config.getEngineName()); + } + + + /** + * Checks if the scanner is registered already for the project + * + * @param project is required + */ + private boolean isScannerRegisteredAlready(Project project) { + return DevAssistUtils.globalScannerController().isRegistered(project, getScannerType()); + } + + + /** + * This method returns the ScanEngine Type + * + * @return ScanEngine + */ + protected ScanEngine getScannerType() { + return ScanEngine.valueOf(config.getEngineName().toUpperCase()); + } + + @Nullable + protected VirtualFile findVirtualFile(String path) { + return LocalFileSystem.getInstance().findFileByPath(path); + } + + protected void initializeScanner() { + } + + @Override + public void dispose() { + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/basescanner/BaseScannerService.java b/src/main/java/com/checkmarx/intellij/devassist/basescanner/BaseScannerService.java new file mode 100644 index 00000000..7a09e903 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/basescanner/BaseScannerService.java @@ -0,0 +1,116 @@ +package com.checkmarx.intellij.devassist.basescanner; + +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.configuration.ScannerConfig; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.psi.PsiFile; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * Base implementation of {@link ScannerService} that wires respective ScannerConig called + * from different scannerServices + * provides helpers for deciding when to scan files and scanners managing temporary folders. + * @param is type of ScanResult produced by concrete scanner Scan method implementations + */ + +@Getter +public class BaseScannerService implements ScannerService { + public ScannerConfig config; + private static final Logger LOGGER = Utils.getLogger(BaseScannerService.class); + + /** + * Creates a new scanner service with the supplied configuration. + * + * @param config configuration values to be used by the scanner + */ + public BaseScannerService(ScannerConfig config) { + this.config = config; + } + + + /** + * Determines whether the file at the given path should be scanned. + * Files inside {@code /node_modules/} are skipped by default. + * + * @param filePath absolute or project-relative file path + * @return {@code true} if the file should be scanned; {@code false} otherwise + */ + public boolean shouldScanFile(String filePath) { + return !filePath.contains("/node_modules/"); + } + + + /** + * Performs a scan of the supplied PSI file. + * Subclasses are expected to override this method with concrete logic. + * + * @param psiFile IntelliJ PSI representation of the file to scan + * @param uri URI identifying the file/location + * @return scan result for the file, or {@code null} if not implemented + */ + public ScanResult scan(PsiFile psiFile, String uri) { + return null; + } + + + + /** + * Builds the path to a temporary sub-folder within the system temp directory. + * + * @param baseDir name of the sub-folder to create under {@code java.io.tmpdir} + * @return absolute path string for the temporary sub-folder + */ + protected String getTempSubFolderPath(String baseDir) { + String tempOS = System.getProperty("java.io.tmpdir"); + Path tempDir = Paths.get(tempOS, baseDir); + return tempDir.toString(); + } + + + /** + * Ensures that the specified temporary folder exists, creating any missing directories. + * + * @param folderPath target temporary folder path + */ + protected void createTempFolder(@NotNull Path folderPath) { + try { + Files.createDirectories(folderPath); + } catch (IOException e) { + LOGGER.warn("Failed to create temporary folder:"+ folderPath, e); + } + } + + + + /** + * Recursively deletes the provided temporary folder and files in it, if it has been created. + * + * @param tempFolder root path of the temporary folder to remove + */ + protected void deleteTempFolder( @NotNull Path tempFolder) { + if (Files.notExists(tempFolder)) { + return; + } + try (Stream walk = Files.walk(tempFolder)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception e) { + LOGGER.warn("Failed to delete file in temp folder:" + path); + } + }); + } catch (IOException e) { + LOGGER.warn("Failed to delete temporary folder:" + tempFolder); + } + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/basescanner/ScannerCommand.java b/src/main/java/com/checkmarx/intellij/devassist/basescanner/ScannerCommand.java new file mode 100644 index 00000000..98d31f19 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/basescanner/ScannerCommand.java @@ -0,0 +1,12 @@ +package com.checkmarx.intellij.devassist.basescanner; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.project.Project; + +public interface ScannerCommand extends Disposable { + void register(Project project); + + void dispose(); + + void deregister(Project project); +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/basescanner/ScannerService.java b/src/main/java/com/checkmarx/intellij/devassist/basescanner/ScannerService.java new file mode 100644 index 00000000..36e73dd0 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/basescanner/ScannerService.java @@ -0,0 +1,13 @@ +package com.checkmarx.intellij.devassist.basescanner; + +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.configuration.ScannerConfig; +import com.intellij.psi.PsiFile; + +public interface ScannerService { + boolean shouldScanFile(String filePath); + + ScanResult scan(PsiFile psiFile, String uri); + + ScannerConfig getConfig(); +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/common/ScanResult.java b/src/main/java/com/checkmarx/intellij/devassist/common/ScanResult.java new file mode 100644 index 00000000..3da51f16 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/common/ScanResult.java @@ -0,0 +1,29 @@ +package com.checkmarx.intellij.devassist.common; + +import com.checkmarx.intellij.devassist.model.ScanIssue; + +import java.util.List; + +/** + * Interface for a scan result. + * + * @param + */ +public interface ScanResult { + + /** + * Retrieves the results of a scan operation. + * This will be used to get the actual results from the original scan engine scan + * + * @return the results of the scan as an object of type T + */ + T getResults(); + + /** + * Get issues from a scan result. Default implementation returns empty list. + * This method wraps an actual scan result and provides a meaningful scan issues list with required details. + * + * @return list of issues + */ + List getIssues(); +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/common/ScannerFactory.java b/src/main/java/com/checkmarx/intellij/devassist/common/ScannerFactory.java new file mode 100644 index 00000000..a3677159 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/common/ScannerFactory.java @@ -0,0 +1,35 @@ +package com.checkmarx.intellij.devassist.common; + +import com.checkmarx.intellij.devassist.basescanner.ScannerService; +import com.checkmarx.intellij.devassist.scanners.oss.OssScannerService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class ScannerFactory { + + private final List> scannerServices; + + public ScannerFactory() { + scannerServices = List.of(new OssScannerService()); + } + + public Optional> findRealTimeScanner(String file) { + return scannerServices.stream().filter(scanner -> scanner.shouldScanFile(file)).findFirst(); + } + + /** + * Returns all the real-time scanners that support the given file + * @param file - file path to be scanned + * @return - list of supported scanners + */ + public List> getAllSupportedScanners(String file) { + List> allSupportedScanners = new ArrayList<>(); + scannerServices.stream().filter(scanner -> + scanner.shouldScanFile(file)) + .findFirst() + .ifPresent(allSupportedScanners::add); + return allSupportedScanners; + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/GlobalScannerController.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/GlobalScannerController.java new file mode 100644 index 00000000..d95dd863 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/GlobalScannerController.java @@ -0,0 +1,183 @@ +package com.checkmarx.intellij.devassist.configuration; + +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.checkmarx.intellij.settings.SettingsListener; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import lombok.Getter; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Application-level controller that tracks the global enablement state of each realtime scanner + * and keeps all open projects synchronized with the latest toggle values from global settings. + */ +@Service(Service.Level.APP) +public final class GlobalScannerController implements SettingsListener { + @Getter + private final Map scannerStateMap = new EnumMap<>(ScanEngine.class); + private final Set activeScannerProjectSet = ConcurrentHashMap.newKeySet(); + + /** + * Get the singleton instance of GlobalScannerController + * + * @return GlobalScannerController + */ + public static GlobalScannerController getInstance() { + return ApplicationManager.getApplication().getService(GlobalScannerController.class); + } + + /** + * Wires the controller into the settings listener bus and seeds the in-memory scanner state + * from the persisted {@link GlobalSettingsState}. + */ + public GlobalScannerController() { + GlobalSettingsState state = GlobalSettingsState.getInstance(); + ApplicationManager.getApplication() + .getMessageBus() + .connect() + .subscribe(SettingsListener.SETTINGS_APPLIED, this); + this.updateScannerState(state); + } + + /** + * Updates the current realtime toggle values from the provided settings state into the + * per-engine cache so callers can query enablement without re-reading settings. + * + * @param state current global settings snapshot + */ + private void updateScannerState(GlobalSettingsState state) { + scannerStateMap.put(ScanEngine.OSS, state.isOssRealtime()); + scannerStateMap.put(ScanEngine.SECRETS, state.isSecretDetectionRealtime()); + scannerStateMap.put(ScanEngine.CONTAINERS, state.isContainersRealtime()); + scannerStateMap.put(ScanEngine.IAC, state.isIacRealtime()); + } + + /** + * Reacts to global settings changes by refreshing the cached scanner state and pushing the + * new configuration out to all open projects. + */ + @Override + public void settingsApplied() { + GlobalSettingsState state = GlobalSettingsState.getInstance(); + synchronized (this) { + updateScannerState(state); + } + this.syncAll(state); + } + + + /** + * Indicates whether the specified scanner type is globally enabled according to the most + * recent settings snapshot. Also checks if MCP is enabled at tenant level. + * + * @param type scanner engine identifier + * @return {@code true} if enabled globally and MCP is enabled; {@code false} otherwise + */ + public synchronized boolean isScannerGloballyEnabled(ScanEngine type) { + GlobalSettingsState state = GlobalSettingsState.getInstance(); + + // If MCP is disabled at tenant level, scanners should be disabled + if (!state.isMcpEnabled()) { + return false; + } + + // Return the scanner's individual state + return scannerStateMap.getOrDefault(type, false); + } + + + /** + * Checks whether the given project has already registered the specified scanner, using + * a composite key derived from the project location hash. + * + * @param project IntelliJ project + * @param type scanner engine identifier + * @return {@code true} if the project is currently registered; {@code false} otherwise + */ + public boolean isRegistered(Project project, ScanEngine type) { + return activeScannerProjectSet.contains(key(project, type)); + } + + + /** + * Builds a unique key per project/scanner pair based on the project's location hash + * (stable even for same-named projects in different directories). + * + * @param project IntelliJ project + * @param type scanner engine identifier + * @return unique string key for the pair + */ + private static String key(Project project, ScanEngine type) { + return project.getLocationHash() + "-" + type.name(); + } + + /** + * Marks the specified project/scanner pair as registered so duplicate registrations + * can be avoided. + * + * @param project IntelliJ project + * @param type scanner engine identifier + */ + public void markRegistered(Project project, ScanEngine type) { + activeScannerProjectSet.add(key(project, type)); + } + + + /** + * Removes the registration mark for the given project/scanner pair, typically after + * de-registration or project disposal. + * + * @param project IntelliJ project + * @param type scanner engine identifier + */ + public void markUnregistered(Project project, ScanEngine type) { + activeScannerProjectSet.remove(key(project, type)); + } + + /** + * Syncs all the opened projects with latest changes in Scanner toggle settings + * Calls @updateFromGlobal method for each project + */ + public synchronized void syncAll(GlobalSettingsState state) { + if (!state.isAuthenticated()) { + for (Project project : ProjectManager.getInstance().getOpenProjects()) { + ScannerLifeCycleManager mgr = project.getService(ScannerLifeCycleManager.class); + if (mgr != null) mgr.stopAll(); + } + return; + } + for (Project project : ProjectManager.getInstance().getOpenProjects()) { + ScannerLifeCycleManager mgr = project.getService(ScannerLifeCycleManager.class); + if (mgr != null) { + mgr.updateFromGlobal(this); + } + } + } + + /** + * Checks if any scanner is enabled + * + * @return true if any scanner is enabled + */ + public boolean checkAnyScannerEnabled() { + return Arrays.stream(ScanEngine.values()) + .anyMatch(this::isScannerGloballyEnabled); + } + + /** + * Get the list of enabled scanners + * + * @return list of enabled scanners + */ + public List getEnabledScanners() { + return Arrays.stream(ScanEngine.values()) + .filter(this::isScannerGloballyEnabled) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/GlobalScannerStartupActivity.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/GlobalScannerStartupActivity.java new file mode 100644 index 00000000..13e53922 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/GlobalScannerStartupActivity.java @@ -0,0 +1,19 @@ +package com.checkmarx.intellij.devassist.configuration; + +import com.checkmarx.intellij.devassist.listeners.DevAssistFileListener; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.StartupActivity; +import org.jetbrains.annotations.NotNull; + +/** + * Creates and wires the GlobalScannerController after project startup. + */ +public class GlobalScannerStartupActivity implements StartupActivity.DumbAware { + @Override + public void runActivity(@NotNull Project project) { + ApplicationManager.getApplication().getService(GlobalScannerController.class); + DevAssistFileListener.register(project); + } +} + diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/ScannerConfig.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/ScannerConfig.java new file mode 100644 index 00000000..2a239c71 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/ScannerConfig.java @@ -0,0 +1,15 @@ +package com.checkmarx.intellij.devassist.configuration; + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class ScannerConfig { + private String engineName; + private String configSection; + private String activateKey; + private String enabledMessage; + private String disabledMessage; + private String errorMessage; +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/ScannerLifeCycleManager.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/ScannerLifeCycleManager.java new file mode 100644 index 00000000..f3d97d89 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/ScannerLifeCycleManager.java @@ -0,0 +1,89 @@ +package com.checkmarx.intellij.devassist.configuration; + +import com.checkmarx.intellij.devassist.registry.ScannerRegistry; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +/** + * ScannerLifeCycleManager is Project level service i.e it is distinct for each project + * Manages the Lifecycle of Scanner for the project + * Triggers the Start and Stop of the Scanner for Project based on global settings + */ + +@Getter +@Service(Service.Level.PROJECT) +public final class ScannerLifeCycleManager implements Disposable { + + private final Project project; + + + /** + * Stores the owning project so scanners can be registered/deregistered against it. + * + * @param project IntelliJ project that this lifecycle manager serves + */ + public ScannerLifeCycleManager(@NotNull Project project) { + this.project = project; + } + + + /** + * Retrieves the project-scoped {@link ScannerRegistry} used to manage scanner commands. + * + * @return scanner registry for the current project + */ + private ScannerRegistry scannerRegistry() { + return this.project.getService(ScannerRegistry.class); + } + + + /** + * Synchronizes every scanner’s state with the latest global settings. For each engine, + * starts it when globally enabled and stops it otherwise. + * + * @param controller global controller supplying enablement flags + */ + public synchronized void updateFromGlobal(@NotNull GlobalScannerController controller) { + for (ScanEngine type : ScanEngine.values()) { + boolean isEnabled = controller.isScannerGloballyEnabled(type); + if (isEnabled) start(type); + else stop(type); + } + } + + /** + * Starts the specified scanner type by registering it with the project’s registry. + * + * @param scannerType scanner engine to start + */ + public void start(ScanEngine scannerType) { + scannerRegistry().registerScanner(scannerType.name()); + } + + /** + * Stops (de-registers) the specified scanner type for this project. + * + * @param scannerType scanner engine to stop + */ + public void stop(ScanEngine scannerType) { + scannerRegistry().deregisterScanner(scannerType.name()); + } + + /** + * Stops all scanner types for this project, regardless of their previous state. + */ + public void stopAll() { + for (ScanEngine type : ScanEngine.values()) { + stop(type); + } + } + + @Override + public void dispose() { + stopAll(); + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpInstallService.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpInstallService.java new file mode 100644 index 00000000..68f894a3 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpInstallService.java @@ -0,0 +1,93 @@ +package com.checkmarx.intellij.devassist.configuration.mcp; + +import com.checkmarx.intellij.commands.TenantSetting; +import com.checkmarx.intellij.settings.global.GlobalSettingsSensitiveState; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.StartupActivity; +import com.intellij.util.concurrency.AppExecutorUtil; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Centralized asynchronous MCP installation logic and IDE startup auto-install activity. + *

+ * Acts both as a service utility (static installSilentlyAsync) and as a post-startup activity when + * registered in plugin.xml. On startup it auto-installs MCP configuration if: + *

    + *
  • User is authenticated
  • + *
  • AI MCP server flag is enabled (TenantSetting.isAiMcpServerEnabled(state, sensitiveState))
  • + *
  • A credential token/API key is available
  • + *
+ * If any condition fails the auto-install silently skips (debug logged). + */ +public final class McpInstallService implements StartupActivity.DumbAware { + + private static final Logger LOG = Logger.getInstance(McpInstallService.class); + + private McpInstallService() { + // utility + startup activity + } + + /** + * Post-startup hook invoked by IntelliJ Platform. Performs conditional auto-install. + */ + @Override + public void runActivity(@NotNull Project project) { + GlobalSettingsState state = GlobalSettingsState.getInstance(); + GlobalSettingsSensitiveState sensitive = GlobalSettingsSensitiveState.getInstance(); + + if (!state.isAuthenticated()) { + LOG.debug("MCP auto-install skipped: user not authenticated."); + return; + } + + boolean aiMcpEnabled; + try { + aiMcpEnabled = TenantSetting.isAiMcpServerEnabled(state, sensitive); + } catch (Exception e) { + LOG.warn("Failed to check AI MCP server status; skipping MCP auto-install.", e); + return; + } + + if (!aiMcpEnabled) { + LOG.debug("AI MCP server disabled; skipping MCP auto-install."); + return; + } + + String token = state.isApiKeyEnabled() + ? sensitive.getApiKey() + : sensitive.getRefreshToken(); + + if (token == null || token.isBlank()) { + LOG.debug("MCP auto-install skipped: no credential token available."); + return; + } + + // Execute asynchronously in background + AppExecutorUtil.getAppExecutorService().execute(() -> installSilentlyAsync(token)); + } + + /** + * Installs MCP configuration asynchronously, without user notifications. + * @param credential token / API key for Authorization header + * @return future resolving to Boolean (true = changed, false = unchanged, null = error) + */ + public static CompletableFuture installSilentlyAsync(String credential) { + if (credential == null || credential.isBlank()) { + LOG.debug("MCP install skipped: empty credential."); + return CompletableFuture.completedFuture(Boolean.FALSE); + } + + return CompletableFuture.supplyAsync(() -> { + try { + return McpSettingsInjector.installForCopilot(credential); // true if modified + } catch (Exception ex) { + LOG.warn("MCP install failed", ex); + return null; // null signals failure + } + }, AppExecutorUtil.getAppExecutorService()); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpSettingsInjector.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpSettingsInjector.java new file mode 100644 index 00000000..10bfeec8 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpSettingsInjector.java @@ -0,0 +1,185 @@ +package com.checkmarx.intellij.devassist.configuration.mcp; + +import com.checkmarx.intellij.Constants; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.intellij.openapi.diagnostic.Logger; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +public final class McpSettingsInjector { + private static final Logger LOG = Logger.getInstance(McpSettingsInjector.class); + private static final ObjectMapper M = new ObjectMapper(); + private static final String FALLBACK_BASE = "https://ast-master-components.dev.cxast.net"; + + private McpSettingsInjector() {} + + /** Adds or updates the Checkmarx MCP server entry. Returns true if file was updated. */ + public static boolean installForCopilot(String token) throws Exception { + String issuer = tryExtractIssuer(token); + String baseUrl = deriveBaseUrlFromIssuer(issuer); + String mcpUrl = baseUrl + "/api/security-mcp/mcp"; + + Path cfg = resolveCopilotMcpConfigPath(); + boolean changed = mergeCheckmarxServer(cfg, mcpUrl, token); + if (changed) { + LOG.info("Installed/updated Checkmarx MCP for Copilot at: " + cfg); + } else { + LOG.debug("MCP config unchanged at: " + cfg); + } + return changed; + } + + /** Removes the Checkmarx MCP server entry. Returns true if removal happened. */ + public static boolean uninstallFromCopilot() throws Exception { + Path cfg = resolveCopilotMcpConfigPath(); + boolean removed = removeCheckmarxServer(cfg); + if (removed) { + LOG.info("Removed Checkmarx MCP from Copilot at: " + cfg); + } else { + LOG.debug("No Checkmarx MCP entry found to remove at: " + cfg); + } + return removed; + } + + /* ---------- Path resolution ---------- */ + + private static Path resolveCopilotMcpConfigPath() { + String os = System.getProperty("os.name").toLowerCase(Locale.ENGLISH); + String home = System.getProperty("user.home"); + + if (os.contains("win")) { + String localAppData = System.getenv("LOCALAPPDATA"); + if (localAppData == null || localAppData.isBlank()) { + throw new IllegalStateException("%LOCALAPPDATA% is not set on Windows."); + } + return Path.of(localAppData, "github-copilot", "intellij", "mcp.json"); + } + + // For macOS and Linux/Unix, use XDG_CONFIG_HOME if set, otherwise fallback to ~/.config + // This fallback logic resolves to ~/.config/github-copilot/intellij/mcp.json + String xdgConfig = System.getenv("XDG_CONFIG_HOME"); + if (xdgConfig != null && !xdgConfig.isBlank()) { + return Path.of(xdgConfig, "github-copilot", "intellij", "mcp.json"); + } + // Fallback to ~/.config/github-copilot/intellij/mcp.json (common on macOS where XDG_CONFIG_HOME is not set) + Path configBase = Path.of(home, ".config"); + return configBase.resolve(Path.of("github-copilot", "intellij", "mcp.json")); + } + + /* ---------- Helpers ---------- */ + + private static String deriveBaseUrlFromIssuer(String issuer) { + if (issuer == null || issuer.isBlank()) return FALLBACK_BASE; + try { + String host = URI.create(issuer).getHost(); + if (host != null && host.contains("iam.checkmarx")) { + host = host.replace("iam", "ast"); + return "https://" + host; + } + } catch (Exception e) { + LOG.warn("Could not derive AST base URL from issuer: " + issuer, e); + } + return FALLBACK_BASE; + } + + private static String tryExtractIssuer(String rawToken) { + if (rawToken == null) return null; + String[] parts = rawToken.split("\\."); + if (parts.length < 2) return null; + try { + byte[] payload = Base64.getUrlDecoder().decode(parts[1]); + String json = new String(payload, StandardCharsets.UTF_8); + Map map = + M.readValue(json, new TypeReference>() {}); + Object iss = map.get("iss"); + return iss != null ? iss.toString() : null; + } catch (Exception e) { + LOG.warn("Failed to parse token payload for issuer", e); + return null; + } + } + + @SuppressWarnings("unchecked") + private static boolean mergeCheckmarxServer(Path configPath, String url, String token) throws Exception { + Map root = readJson(configPath); + Map servers = (Map) root + .getOrDefault("servers", new LinkedHashMap<>()); + + Map headers = new LinkedHashMap<>(); + headers.put("cx-origin", Constants.JET_BRAINS_AGENT_NAME); + headers.put("Authorization", token); + + Map serverEntry = new LinkedHashMap<>(); + serverEntry.put("url", url); + Map requestInit = new LinkedHashMap<>(); + requestInit.put("headers", headers); + serverEntry.put("requestInit", requestInit); + + Map existing = (Map) servers.get(Constants.TOOL_WINDOW_ID); + boolean changed = !Objects.equals(existing, serverEntry); + + if (!changed) { + return false; + } + + servers.put(Constants.TOOL_WINDOW_ID, serverEntry); + root.put("servers", servers); + + Files.createDirectories(configPath.getParent()); + Files.writeString(configPath, + M.writerWithDefaultPrettyPrinter().writeValueAsString(root)); + return true; + } + + @SuppressWarnings("unchecked") + private static boolean removeCheckmarxServer(Path configPath) throws Exception { + if (!Files.exists(configPath)) return false; + + Map root = readJson(configPath); + Object serversObj = root.get("servers"); + if (!(serversObj instanceof Map)) return false; + + Map servers = (Map) serversObj; + boolean removed = servers.remove(Constants.TOOL_WINDOW_ID) != null; + if (!removed) { + return false; + } + + root.put("servers", servers); + Files.writeString(configPath, + M.writerWithDefaultPrettyPrinter().writeValueAsString(root)); + return true; + } + + private static Map readJson(Path path) { + if (!Files.exists(path)) return emptyServersRoot(); + try { + String content = stripLineComments(Files.readString(path)); + Map map = + M.readValue(content, new TypeReference>() {}); + return (map == null || map.isEmpty()) ? emptyServersRoot() : map; + } catch (Exception e) { + LOG.warn("Failed to read existing Copilot MCP config, starting fresh", e); + return emptyServersRoot(); + } + } + + private static Map emptyServersRoot() { + return new LinkedHashMap<>(Collections.singletonMap("servers", new LinkedHashMap<>())); + } + + private static String stripLineComments(String s) { + return s.replaceAll("(?m)^\\s*//.*$", ""); + } + + /** Public accessor used by UI components to locate the MCP configuration file. */ + public static Path getMcpJsonPath() { + return resolveCopilotMcpConfigPath(); + } +} + diff --git a/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpUninstallHandler.java b/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpUninstallHandler.java new file mode 100644 index 00000000..798c9b2c --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/configuration/mcp/McpUninstallHandler.java @@ -0,0 +1,34 @@ +package com.checkmarx.intellij.devassist.configuration.mcp; + +import com.intellij.ide.plugins.DynamicPluginListener; +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.openapi.diagnostic.Logger; +import org.jetbrains.annotations.NotNull; + +/** + * Handles MCP cleanup when the Checkmarx plugin is uninstalled. + * Calls the existing McpSettingsInjector.uninstallFromCopilot() method. + */ +public final class McpUninstallHandler implements DynamicPluginListener { + + private static final Logger LOG = Logger.getInstance(McpUninstallHandler.class); + private static final String CHECKMARX_PLUGIN_ID = "com.checkmarx.checkmarx-ast-jetbrains-plugin"; + + @Override + public void beforePluginUnload(@NotNull IdeaPluginDescriptor pluginDescriptor, boolean isUpdate) { + // Only clean up when our plugin is being uninstalled (not updated) + if (!isUpdate && CHECKMARX_PLUGIN_ID.equals(pluginDescriptor.getPluginId().getIdString())) { + try { + // Call the existing uninstall method directly + boolean removed = McpSettingsInjector.uninstallFromCopilot(); + if (removed) { + LOG.info("Checkmarx MCP configuration removed during plugin uninstallation"); + } else { + LOG.debug("No Checkmarx MCP configuration found during plugin uninstallation"); + } + } catch (Exception ex) { + LOG.warn("Failed to remove Checkmarx MCP configuration during plugin uninstallation", ex); + } + } + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/inspection/RealtimeInspection.java b/src/main/java/com/checkmarx/intellij/devassist/inspection/RealtimeInspection.java new file mode 100644 index 00000000..c2f1028f --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/inspection/RealtimeInspection.java @@ -0,0 +1,332 @@ +package com.checkmarx.intellij.devassist.inspection; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.basescanner.ScannerService; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.common.ScannerFactory; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemDecorator; +import com.checkmarx.intellij.devassist.problems.ProblemHelper; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.problems.ScanIssueProcessor; +import com.checkmarx.intellij.devassist.remediation.CxOneAssistFix; +import com.checkmarx.intellij.devassist.ui.ProblemDescription; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static java.lang.String.format; + +/** + * The RealtimeInspection class extends LocalInspectionTool and is responsible for + * performing real-time inspections of files within a project. It uses various + * utility classes to scan files, identify issues, and provide problem descriptors + * for on-the-fly or manual inspections. + *

+ * This class maintains a cache of file modification timestamps to optimize its + * behavior, avoiding repeated scans of unchanged files. It supports integration + * with real-time scanner services and provides problem highlights and fixes for + * identified issues. + */ +public class RealtimeInspection extends LocalInspectionTool { + + private static final Logger LOGGER = Utils.getLogger(RealtimeInspection.class); + private final Map fileTimeStamp = new ConcurrentHashMap<>(); + private final ScannerFactory scannerFactory = new ScannerFactory(); + private final ProblemDecorator problemDecorator = new ProblemDecorator(); + private final Key key = Key.create(Constants.RealTimeConstants.THEME); + + /** + * Inspects the given PSI file and identifies potential issues or problems by leveraging + * scanning services and generating problem descriptors. + * + * @param file the PSI file to be checked; must not be null + * @param manager the inspection manager used to create problem descriptors; must not be null + * @param isOnTheFly a flag that indicates whether the inspection is executed on-the-fly + * @return an array of {@link ProblemDescriptor} representing the detected issues, or an empty array if no issues were found + */ + @Override + public ProblemDescriptor[] checkFile(@NotNull PsiFile file, @NotNull InspectionManager manager, boolean isOnTheFly) { + VirtualFile virtualFile = file.getVirtualFile(); + if (Objects.isNull(virtualFile)) { + LOGGER.warn(format("RTS: VirtualFile object not found for file: %s.", file.getName())); + resetResults(file.getProject()); + return ProblemDescriptor.EMPTY_ARRAY; + } + // On remediation process GitHub Copilot generating the fake file with the name Dummy.txt, so ignoring that file. + if (isAgentEvent(virtualFile)) { + LOGGER.warn(format("RTS: Received copilot event for file: %s. Skipping file..", file.getName())); + return ProblemDescriptor.EMPTY_ARRAY; + } + if (!Utils.isUserAuthenticated() || !DevAssistUtils.isAnyScannerEnabled()) { + LOGGER.warn(format("RTS: User not authenticated or No scanner is enabled, skipping file: %s", file.getName())); + resetResults(file.getProject()); + return ProblemDescriptor.EMPTY_ARRAY; + } + Document document = PsiDocumentManager.getInstance(file.getProject()).getDocument(file); + if (Objects.isNull(document)) { + LOGGER.warn(format("RTS: Document not found for file: %s.", file.getName())); + resetResults(file.getProject()); + return ProblemDescriptor.EMPTY_ARRAY; + } + List> supportedScanners = getSupportedEnabledScanner(virtualFile.getPath()); + if (supportedScanners.isEmpty()) { + LOGGER.warn(format("RTS: No supported scanner enabled for this file: %s.", file.getName())); + resetResults(file.getProject()); + return ProblemDescriptor.EMPTY_ARRAY; + } + ProblemHolderService problemHolderService = ProblemHolderService.getInstance(file.getProject()); + /* + * Check if the file is already scanned and if the problem descriptors are valid. + * If a file is already scanned and problem descriptors are valid, then return the existing problem descriptors for the enabled scanners. + */ + if (fileTimeStamp.containsKey(virtualFile.getPath()) && fileTimeStamp.get(virtualFile.getPath()) == (file.getModificationStamp()) + && isProblemDescriptorValid(problemHolderService, virtualFile.getPath(), file)) { + LOGGER.info(format("RTS: File: %s is already scanned, retrieving existing results.", file.getName())); + return getExistingProblemsForEnabledScanners(problemHolderService, virtualFile.getPath(), document, file, supportedScanners); + } + fileTimeStamp.put(virtualFile.getPath(), file.getModificationStamp()); + file.putUserData(key, DevAssistUtils.isDarkTheme()); + return scanFileAndCreateProblemDescriptors(file, manager, isOnTheFly, supportedScanners, document, problemHolderService, virtualFile); + } + + /** + * Clears all problem descriptors and gutter icons for the given project. + * + * @param project the project to reset results for + */ + private void resetResults(Project project) { + ProblemDecorator.removeAllGutterIcons(project); + ProblemHolderService.getInstance(project).removeAllProblemDescriptors(); + } + + /** + * Retrieves all supported instances of {@link ScannerService} for handling real-time scanning + * of the specified file. The method checks available scanner services to determine if + * any of them is suited to handle the given file path. + * + * @param filePath the path of the file as a string, used to identify an applicable scanner service; must not be null or empty + * @return an {@link Optional} containing the matching {@link ScannerService} if found, or an empty {@link Optional} if no appropriate service exists + */ + private List> getSupportedEnabledScanner(String filePath) { + List> supportedScanners = scannerFactory.getAllSupportedScanners(filePath); + if (supportedScanners.isEmpty()) { + LOGGER.warn(format("RTS: No supported scanner found for this file path: %s.", filePath)); + return Collections.emptyList(); + } + return supportedScanners.stream() + .filter(scannerService -> + DevAssistUtils.isScannerActive(scannerService.getConfig().getEngineName())) + .collect(Collectors.toList()); + } + + /** + * Checks if the virtual file is a GitHub Copilot-generated file. + * E.g., On opening of GitHub Copilot, it's generating the fake file with the name Dummy.txt, so ignoring that file. + * + * @param virtualFile - VirtualFile object of the file. + * @return true if the file is a GitHub Copilot-generated file, false otherwise. + */ + private boolean isAgentEvent(VirtualFile virtualFile) { + return Constants.RealTimeConstants.AGENT_DUMMY_FILES.stream() + .anyMatch(filePath -> filePath.equals(virtualFile.getPath())); + } + + /** + * Checks if the problem descriptor for the given file path is valid. + * Scan file on theme change, as the inspection tooltip doesn't support dynamic icon change in the tooltip description. + * + * @param problemHolderService the problem holder service + * @param path the file path + * @return true if the problem descriptor is valid, false otherwise + */ + private boolean isProblemDescriptorValid(ProblemHolderService problemHolderService, String path, PsiFile file) { + if (file.getUserData(key) != null && !Objects.equals(file.getUserData(key), DevAssistUtils.isDarkTheme())) { + ProblemDescription.reloadIcons(); // reload problem descriptions icons on theme change + LOGGER.info("RTS: Theme changed, resetting problem descriptors."); + return false; + } + return !problemHolderService.getProblemDescriptors(path).isEmpty(); + } + + /** + * Gets the existing problem descriptors for the given file path and enabled scanners. + * + * @param problemHolderService the problem holder service. + * @param filePath the file path. + * @return the problem descriptors. + */ + private ProblemDescriptor[] getExistingProblemsForEnabledScanners(ProblemHolderService problemHolderService, String filePath, Document document, + PsiFile file, List> supportedEnabledScanners) { + List problemDescriptorsList = problemHolderService.getProblemDescriptors(filePath); + /* + * If a file already scanned and after that if scanner settings are changed (enabled/disabled), + * we need to filter the existing problems and return only those which are related to enabled scanners + */ + List enabledScanners = supportedEnabledScanners.stream() + .map(scannerService -> + ScanEngine.valueOf(scannerService.getConfig().getEngineName().toUpperCase())) + .collect(Collectors.toList()); + + if (problemDescriptorsList.isEmpty() || enabledScanners.isEmpty()) { + LOGGER.warn(format("RTS: No existing problem descriptors found for file: %s or no enabled scanners found.", filePath)); + return ProblemDescriptor.EMPTY_ARRAY; + } + List scanIssueList = problemHolderService.getScanIssueByFile(filePath); + if (scanIssueList.isEmpty()) { + LOGGER.warn(format("RTS: No existing scan issues found for file: %s.", filePath)); + return ProblemDescriptor.EMPTY_ARRAY; + } + List enabledScannerProblems = new ArrayList<>(); + for (ProblemDescriptor descriptor : problemDescriptorsList) { + try { + CxOneAssistFix cxOneAssistFix = (CxOneAssistFix) descriptor.getFixes()[0]; + if (Objects.nonNull(cxOneAssistFix) && enabledScanners.contains(cxOneAssistFix.getScanIssue().getScanEngine())) { + enabledScannerProblems.add(descriptor); + } + } catch (Exception e) { + LOGGER.debug("RTS: Exception occurred while getting existing problems for enabled scanner for file: {} ", + filePath, e.getMessage()); + enabledScannerProblems.add(descriptor); + } + } + List enabledScanIssueList = scanIssueList.stream() + .filter(scanIssue -> enabledScanners.contains(scanIssue.getScanEngine())) + .collect(Collectors.toList()); + + // Update gutter icons and problem descriptors for the file according to the latest state of scan settings. + problemDecorator.restoreGutterIcons(file.getProject(), file, enabledScanIssueList, document); + problemHolderService.addProblemDescriptors(filePath, enabledScannerProblems); + return enabledScannerProblems.toArray(new ProblemDescriptor[0]); + } + + /** + * Builds a {@link ProblemHelper.ProblemHelperBuilder} instance with the specified parameters. + * + * @param file the PSI file to be scanned + * @param manager the inspection manager used to create problem descriptors + * @param isOnTheFly a flag that indicates whether the inspection is executed on-the-fly + * @param document the document containing the file to be scanned + * @return a {@link ProblemHelper.ProblemHelperBuilder} instance + */ + private ProblemHelper.ProblemHelperBuilder buildHelper(@NotNull PsiFile file, @NotNull InspectionManager manager, + boolean isOnTheFly, Document document, List> supportedScanners, + String path, ProblemHolderService problemHolderService) { + return ProblemHelper.builder() + .file(file) + .manager(manager) + .isOnTheFly(isOnTheFly) + .document(document) + .supportedScanners(supportedScanners) + .filePath(path) + .problemHolderService(problemHolderService); + } + + /** + * Scans the given PSI file and creates problem descriptors for any identified issues. + * + * @param file the PsiFile representing the file to be scanned; must not be null + * @param manager the inspection manager used to create problem descriptors; must not be null + * @param isOnTheFly a flag that indicates whether the inspection is executed on-the-fly + * @param supportedScanners the list of supported scanner services + * @param document the document containing the file to be scanned + * @param problemHolderService the problem holder service + * @param virtualFile the virtual file + * @return ProblemDescriptor[] array of problem descriptors + */ + private ProblemDescriptor[] scanFileAndCreateProblemDescriptors(@NotNull PsiFile file, @NotNull InspectionManager manager, boolean isOnTheFly, + List> supportedScanners, Document document, + ProblemHolderService problemHolderService, VirtualFile virtualFile) { + + ProblemHelper.ProblemHelperBuilder problemHelperBuilder = buildHelper(file, manager, isOnTheFly, document, + supportedScanners, virtualFile.getPath(), problemHolderService); + + List scanResultDescriptors = startScanAndCreateProblemDescriptors(problemHelperBuilder); + if (scanResultDescriptors.isEmpty()) { + LOGGER.info(format("RTS: No issues found for file: %s ", file.getName())); + } + LOGGER.info(format("RTS: Scanning completed and descriptors created: %s for file: %s", scanResultDescriptors.size(), file.getName())); + return scanResultDescriptors.toArray(new ProblemDescriptor[0]); + } + + /** + * Scans the given PSI file and creates problem descriptors for any identified issues. + * + * @param problemHelperBuilder - The {@link ProblemHelper} + * @return a list of {@link ProblemDescriptor} representing the detected issues, or an empty list if no issues were found + */ + private List startScanAndCreateProblemDescriptors(ProblemHelper.ProblemHelperBuilder problemHelperBuilder) { + ProblemHelper problemHelper = problemHelperBuilder.build(); + List allProblems = new ArrayList<>(); + List allScanIssues = new ArrayList<>(); + + for (ScannerService scannerService : problemHelper.getSupportedScanners()) { + ScanResult scanResult = scanFile(scannerService, problemHelper.getFile(), problemHelper.getFilePath()); + if (Objects.isNull(scanResult)) continue; + problemHelperBuilder.scanResult(scanResult); + allProblems.addAll(createProblemDescriptors(problemHelperBuilder.build())); + allScanIssues.addAll(scanResult.getIssues()); + } + problemHelper.getProblemHolderService().addProblemDescriptors(problemHelper.getFilePath(), allProblems); + problemHelper.getProblemHolderService().addProblems(problemHelper.getFilePath(), allScanIssues); + return allProblems; + } + + /** + * Scans the given PSI file at the specified path using an appropriate real-time scanner, + * if available and active. + * + * @param scannerService - ScannerService object of found scan engine + * @param file the PsiFile representing the file to be scanned; must not be null + * @param path the string representation of the file path to be scanned; must not be null or empty + * @return a {@link ScanResult} instance containing the results of the scan, or null if no + * active and suitable scanner is found + */ + private ScanResult scanFile(ScannerService scannerService, @NotNull PsiFile file, @NotNull String path) { + try { + LOGGER.info(format("RTS: Started scanning file: %s using scanner: %s", path, scannerService.getConfig().getEngineName())); + return scannerService.scan(file, path); + } catch (Exception e) { + LOGGER.debug("RTS: Exception occurred while scanning file: {} ", path, e.getMessage()); + return null; + } + } + + /** + * Creates a list of {@link ProblemDescriptor} objects based on the issues identified in the scan result. + * This method processes the scan issues for the specified file and uses the provided InspectionManager + * to generate corresponding problem descriptors, if applicable. + * + * @param problemHelper - The {@link ProblemHelper}} instance containing necessary context for creating problem descriptors + * @return a list of {@link ProblemDescriptor}; an empty list is returned if no issues are found or processed successfully + */ + private List createProblemDescriptors(ProblemHelper problemHelper) { + List problems = new ArrayList<>(); + ProblemDecorator.removeAllGutterIcons(problemHelper.getFile().getProject()); + ScanIssueProcessor processor = new ScanIssueProcessor(problemHelper, this.problemDecorator); + + for (ScanIssue scanIssue : problemHelper.getScanResult().getIssues()) { + ProblemDescriptor descriptor = processor.processScanIssue(scanIssue); + if (descriptor != null) { + problems.add(descriptor); + } + } + LOGGER.info(format("RTS: Problem descriptors created: %s for file: %s", problems.size(), problemHelper.getFile().getName())); + return problems; + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/listeners/DevAssistFileListener.java b/src/main/java/com/checkmarx/intellij/devassist/listeners/DevAssistFileListener.java new file mode 100644 index 00000000..e630da6c --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/listeners/DevAssistFileListener.java @@ -0,0 +1,119 @@ +package com.checkmarx.intellij.devassist.listeners; + +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemDecorator; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.util.messages.MessageBusConnection; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import static java.lang.String.format; + +/** + * DevAssistFileListener is responsible for listening for file open events and restoring + */ +public class DevAssistFileListener { + + private static final Logger LOGGER = Utils.getLogger(DevAssistFileListener.class); + private static final ProblemDecorator PROBLEM_DECORATOR_INSTANCE = new ProblemDecorator(); + + private DevAssistFileListener() { + // Private constructor to prevent instantiation + } + + /** + * Registers the file listener to the given project. + * + * @param project the project + */ + public static void register(Project project) { + MessageBusConnection connection = project.getMessageBus().connect(); + connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() { + @Override + public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + Project project = source.getProject(); + String path = file.getPath(); + PsiFile psiFile = PsiManager.getInstance(project).findFile(file); + // fallback if problem descriptors exist and gutter icons are not restored + restoreGutterIcons(project, psiFile, path); + } + + @Override + public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + removeProblemDescriptor(source.getProject(), file.getPath()); + } + }); + } + + /** + * Restores problems for the given file. + * + * @param project the project + * @param psiFile the psi file + * @param filePath the file path + */ + private static void restoreGutterIcons(Project project, PsiFile psiFile, String filePath) { + if (psiFile == null) return; + + List enabledScanEngines = DevAssistUtils.globalScannerController().getEnabledScanners(); + if (enabledScanEngines.isEmpty()) { + LOGGER.warn(format("RTS-Listener: No scanner is enabled, skipping restoring gutter icons for file: %s", psiFile.getName())); + return; + } + ProblemHolderService problemHolderService = ProblemHolderService.getInstance(project); + List problemDescriptorList = problemHolderService.getProblemDescriptors(filePath); + if (problemDescriptorList.isEmpty()) { + return; + } + List scanIssueList = problemHolderService.getScanIssueByFile(filePath); + if (scanIssueList.isEmpty()) return; + + List enabledEngineScanIssues = getScanIssuesForEnabledScanner(enabledScanEngines, scanIssueList); + if (enabledEngineScanIssues.isEmpty()) return; + + Document document = PsiDocumentManager.getInstance(project).getDocument(psiFile); + if (document == null) return; + PROBLEM_DECORATOR_INSTANCE.restoreGutterIcons(project, psiFile, enabledEngineScanIssues, document); + } + + /** + * Filters the given scan issue list for the enabled scanner. + * + * @param enabledScanEngines - list of enabled scanner + * @param scanIssueList - list of scan issue + * @return - filtered list of scan issue + */ + private static List getScanIssuesForEnabledScanner(List enabledScanEngines, List scanIssueList) { + return scanIssueList.stream() + .filter(scanIssue -> enabledScanEngines.stream() + .anyMatch(engine -> scanIssue.getScanEngine().equals(engine))) + .collect(Collectors.toList()); + } + + /** + * Removes all problem descriptors for the given file. + * + * @param project the project + * @param path the file path + */ + public static void removeProblemDescriptor(Project project, String path) { + if (Objects.isNull(path) || path.isEmpty()) return; + ProblemHolderService.getInstance(project).removeProblemDescriptorsForFile(path); + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/devassist/model/Location.java b/src/main/java/com/checkmarx/intellij/devassist/model/Location.java new file mode 100644 index 00000000..b869d1c1 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/model/Location.java @@ -0,0 +1,29 @@ +package com.checkmarx.intellij.devassist.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +/** + * Represents a specific location within a file. + * This class is primarily used for identifying specific ranges in code, such as + * vulnerable code segments or other points of interest identified during a scan. + * Instances of this class are used within scan result models such as {@code ScanIssue}. + * + * Attributes: + * - line: The line number in the file where the vulnerability is found. + * - startIndex: The starting character index within the line for the vulnerability. + * - endIndex: The ending character index within the line for the vulnerability. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Location { + + private int line; + private int startIndex; + private int endIndex; +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/model/ScanIssue.java b/src/main/java/com/checkmarx/intellij/devassist/model/ScanIssue.java new file mode 100644 index 00000000..c476109b --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/model/ScanIssue.java @@ -0,0 +1,65 @@ +package com.checkmarx.intellij.devassist.model; + +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a scan issue detected during a real-time scan engine. + * This class captures detailed information about issues identified in a scanned project, file, or package. + *

+ * Each scan issue can potentially have multiple locations and vulnerabilities linked to it + * for providing comprehensive details about the issue's scope. + *

+ * Attributes: + * - severity: Severity level of the issue, such as "Critical", "High", "Medium", etc. + * - title: Rule name or package name associated with the issue. + * - description: Detailed explanation or context describing the issue. + * - remediationAdvise: Suggestion or advice for addressing the issue, if available. + * - packageVersion: Version of the package linked to the issue (may be null for rule-based issues). + * - cve: Associated CVE identifier (if any) or null if not applicable. + * - scanEngine: The name of the scanning engine responsible for detecting the issue (e.g., OSS, SECRET). + * - filePath: The path to the file in which the issue is detected. + * - locations: A list of location details highlighting vulnerable code ranges or other points of concern. + * - vulnerabilities: A list of associated vulnerabilities providing additional insights into the issue. + * + * @apiNote This class is not intended to be instantiated directly. This should be built from the respective scanner adapter classes. + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ScanIssue { + + private String severity; + private String title; + private String description; + private String remediationAdvise; + private String packageVersion; + private String packageManager; + private String cve; + private ScanEngine scanEngine; + private String filePath; + + /** + * A list of specific locations within the file that are related to the scan issue. + * Each location typically identifies a vulnerable code segment or point of concern, + * including its line number and character range within the line. + */ + private List locations = new ArrayList<>(); + + /** + * A list of associated vulnerabilities providing additional insights into the scan issue. + * Each vulnerability represents a specific security risk or flaw detected during the scan, + * including attributes such as the CVE identifier, description, severity level, etc. + *

+ * Vulnerabilities are linked to the scan issue to provide context and help users understand + * the potential impact and required actions to address the identified risks. + */ + private List vulnerabilities = new ArrayList<>(); +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/model/Vulnerability.java b/src/main/java/com/checkmarx/intellij/devassist/model/Vulnerability.java new file mode 100644 index 00000000..9627d00b --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/model/Vulnerability.java @@ -0,0 +1,29 @@ +package com.checkmarx.intellij.devassist.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Represents a specific vulnerability identified during a real-time scan. + * This class is used to capture detailed information about a vulnerability. + *

+ * Attributes: + * - cve: The Common Vulnerabilities and Exposures (CVE) identifier associated with the vulnerability. + * - description: A detailed description of the vulnerability, explaining its nature and impact. + * - severity: The severity level of the vulnerability, such as "Critical", "High", "Medium", etc. + * - remediationAdvise: Suggested remediation or fix advice, if available, to address the vulnerability. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Vulnerability { + + private String cve; + private String description; + private String severity; + private String remediationAdvise;// Fix suggestion, if available + private String fixVersion; +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemBuilder.java b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemBuilder.java new file mode 100644 index 00000000..2f2e9a6d --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemBuilder.java @@ -0,0 +1,99 @@ +package com.checkmarx.intellij.devassist.problems; + +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.CxOneAssistFix; +import com.checkmarx.intellij.devassist.remediation.ViewDetailsFix; +import com.checkmarx.intellij.devassist.ui.ProblemDescription; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.util.SeverityLevel; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; + +/** + * The ProblemBuilder class is a utility class responsible for constructing + * ProblemDescriptor objects based on specific scan issues identified within a PsiFile. + * It encapsulates the logic to derive necessary problem details such as text range, + * description, and highlight type, delegating specific computations to an instance + * of ProblemDecorator. + *

+ * This class cannot be instantiated. + */ +public class ProblemBuilder { + + private static final Map SEVERITY_HIGHLIGHT_TYPE_MAP = new HashMap<>(); + private static final ProblemDescription PROBLEM_DESCRIPTION_INSTANCE = new ProblemDescription(); + + /* + * Static initializer to initialize the mapping from severity levels to problem highlight types. + */ + static { + initSeverityToHighlightMap(); + } + + /** + * Private constructor to prevent instantiation. + */ + private ProblemBuilder() { + } + + /** + * Initializes the mapping from severity levels to problem highlight types. + */ + private static void initSeverityToHighlightMap() { + SEVERITY_HIGHLIGHT_TYPE_MAP.put(SeverityLevel.MALICIOUS.getSeverity(), ProblemHighlightType.GENERIC_ERROR); + SEVERITY_HIGHLIGHT_TYPE_MAP.put(SeverityLevel.CRITICAL.getSeverity(), ProblemHighlightType.GENERIC_ERROR); + SEVERITY_HIGHLIGHT_TYPE_MAP.put(SeverityLevel.HIGH.getSeverity(), ProblemHighlightType.GENERIC_ERROR); + SEVERITY_HIGHLIGHT_TYPE_MAP.put(SeverityLevel.MEDIUM.getSeverity(), ProblemHighlightType.WARNING); + SEVERITY_HIGHLIGHT_TYPE_MAP.put(SeverityLevel.LOW.getSeverity(), ProblemHighlightType.WEAK_WARNING); + } + + /** + * Builds a ProblemDescriptor for the given scan issue. + * + * @param file the PsiFile being inspected + * @param manager the InspectionManager + * @param scanIssue the scan issue + * @param document the document + * @param problemLineNumber the line number where the problem was found + * @param isOnTheFly whether the inspection is on-the-fly + * @return a ProblemDescriptor instance + */ + static ProblemDescriptor build(@NotNull PsiFile file, @NotNull InspectionManager manager, + @NotNull ScanIssue scanIssue, @NotNull Document document, + int problemLineNumber, boolean isOnTheFly) { + + TextRange problemRange = DevAssistUtils.getTextRangeForLine(document, problemLineNumber); + String description = PROBLEM_DESCRIPTION_INSTANCE.formatDescription(scanIssue); + ProblemHighlightType highlightType = determineHighlightType(scanIssue); + + return manager.createProblemDescriptor( + file, + problemRange, + description, + highlightType, + isOnTheFly, + new CxOneAssistFix(scanIssue), + new ViewDetailsFix(scanIssue) + /*new IgnoreVulnerabilityFix(scanIssue), + new IgnoreAllThisTypeFix(scanIssue)*/ + ); + } + + /** + * Determines the highlight type for a specific scan detail. + * + * @param scanIssue the scan detail + * @return the problem highlight type + */ + private static ProblemHighlightType determineHighlightType(ScanIssue scanIssue) { + return SEVERITY_HIGHLIGHT_TYPE_MAP.getOrDefault(scanIssue.getSeverity(), ProblemHighlightType.WEAK_WARNING); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemDecorator.java b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemDecorator.java new file mode 100644 index 00000000..a9096e29 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemDecorator.java @@ -0,0 +1,284 @@ +package com.checkmarx.intellij.devassist.problems; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.Location; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.util.SeverityLevel; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.colors.CodeInsightColors; +import com.intellij.openapi.editor.colors.EditorColorsManager; +import com.intellij.openapi.editor.colors.TextAttributesKey; +import com.intellij.openapi.editor.markup.*; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.util.*; + +/** + * ProblemDecorator class responsible to provides utility methods for managing problem, highlighting and gutter icons. + */ +@Getter +public class ProblemDecorator { + + private static final Logger LOGGER = Utils.getLogger(ProblemDecorator.class); + private final Map severityHighlighterLayerMap = new HashMap<>(); + + public ProblemDecorator() { + initSeverityHighlighterLayerMap(); + } + + /** + * Initializes the mapping from severity levels to highlighter layers. + */ + private void initSeverityHighlighterLayerMap() { + severityHighlighterLayerMap.put(Constants.MALICIOUS_SEVERITY, HighlighterLayer.ERROR); + severityHighlighterLayerMap.put(Constants.CRITICAL_SEVERITY, HighlighterLayer.ERROR); + severityHighlighterLayerMap.put(Constants.HIGH_SEVERITY, HighlighterLayer.ERROR); + severityHighlighterLayerMap.put(Constants.MEDIUM_SEVERITY, HighlighterLayer.WARNING); + severityHighlighterLayerMap.put(Constants.LOW_SEVERITY, HighlighterLayer.WEAK_WARNING); + } + + /** + * Adds a gutter icon at the line of the given PsiElement. + */ + public void highlightLineAddGutterIconForProblem(@NotNull Project project, @NotNull PsiFile file, + ScanIssue scanIssue, boolean isProblem, int problemLineNumber) { + ApplicationManager.getApplication().invokeLater(() -> { + Editor editor = FileEditorManager.getInstance(project).getSelectedTextEditor(); + if (editor == null) return; + + if (!Objects.equals(editor.getDocument(), PsiDocumentManager.getInstance(project).getDocument(file))) { + // Only decorate the active editor of this file + return; + } + MarkupModel markupModel = editor.getMarkupModel(); + boolean isFirstLocation = true; + for (Location location : scanIssue.getLocations()) { + int targetLine = location.getLine(); + highlightLocationInEditor(editor, markupModel, targetLine, scanIssue, isFirstLocation, isProblem, problemLineNumber); + isFirstLocation = false; + } + }); + } + + /** + * Highlights a specific location in the editor and optionally adds a gutter icon. + * + * @param editor the editor instance + * @param markupModel the markup model for highlighting + * @param targetLine the line number to highlight (1-based) + * @param scanIssue the scan package containing severity information + * @param addGutterIcon whether to add a gutter icon for this location + */ + private void highlightLocationInEditor(Editor editor, MarkupModel markupModel, int targetLine, + ScanIssue scanIssue, boolean addGutterIcon, boolean isProblem, int problemLineNumber) { + TextRange textRange = DevAssistUtils.getTextRangeForLine(editor.getDocument(), targetLine); + TextAttributes textAttributes = createTextAttributes(scanIssue.getSeverity()); + + RangeHighlighter highlighter = markupModel.addLineHighlighter( + targetLine - 1, 0, null); + + if (isProblem) { + highlighter = markupModel.addRangeHighlighter( + textRange.getStartOffset(), + textRange.getEndOffset(), + determineHighlighterLayer(scanIssue), + textAttributes, + HighlighterTargetArea.EXACT_RANGE + ); + } + boolean alreadyHasGutterIcon = isAlreadyHasGutterIcon(markupModel, editor, problemLineNumber); + if (addGutterIcon && !alreadyHasGutterIcon) { + addGutterIcon(highlighter, scanIssue.getSeverity()); + } + } + + /** + * Creates text attributes for error highlighting with wave underscore effect. + * + * @return the configured text attributes + */ + private TextAttributes createTextAttributes(String severity) { + TextAttributes errorAttrs = EditorColorsManager.getInstance() + .getGlobalScheme().getAttributes(getCodeInsightColors(severity)); + + TextAttributes attr = new TextAttributes(); + attr.setEffectType(EffectType.WAVE_UNDERSCORE); + attr.setEffectColor(errorAttrs.getEffectColor()); + attr.setForegroundColor(errorAttrs.getForegroundColor()); + attr.setBackgroundColor(null); + return attr; + } + + /** + * Gets the CodeInsightColors key based on severity. + * + * @param severity the severity + * @return the text attributes key for the given severity + */ + private TextAttributesKey getCodeInsightColors(String severity) { + if (severity.equalsIgnoreCase(SeverityLevel.MALICIOUS.getSeverity()) || + severity.equalsIgnoreCase(SeverityLevel.CRITICAL.getSeverity()) + || severity.equalsIgnoreCase(SeverityLevel.HIGH.getSeverity())) { + return CodeInsightColors.ERRORS_ATTRIBUTES; + } else if (severity.equalsIgnoreCase(SeverityLevel.MEDIUM.getSeverity())) { + return CodeInsightColors.WARNINGS_ATTRIBUTES; + } else { + return CodeInsightColors.WEAK_WARNING_ATTRIBUTES; + } + } + + /** + * Adds a gutter icon to the highlighter. + * + * @param highlighter the highlighter + * @param severity the severity + */ + private void addGutterIcon(RangeHighlighter highlighter, String severity) { + highlighter.setGutterIconRenderer(new GutterIconRenderer() { + + @Override + public @NotNull Icon getIcon() { + return getGutterIconBasedOnStatus(severity); + } + + @Override + public @NotNull Alignment getAlignment() { + return Alignment.LEFT; + } + + @Override + public String getTooltipText() { + return severity; + } + + @Override + public boolean equals(Object obj) { + return obj == this; + } + + @Override + public int hashCode() { + return System.identityHashCode(this); + } + }); + } + + /** + * Checks if the highlighter already has a gutter icon for the given line. + * + * @param markupModel the markup model + * @param editor the editor + * @param line the line + * @return true if the highlighter already has a gutter icon for the given line, false otherwise + * @apiNote this method is particularly used to avoid adding duplicate gutter icons in the file for duplicate dependencies. + */ + private boolean isAlreadyHasGutterIcon(MarkupModel markupModel, Editor editor, int line) { + return Arrays.stream(markupModel.getAllHighlighters()) + .anyMatch(highlighter -> { + GutterIconRenderer renderer = highlighter.getGutterIconRenderer(); + if (renderer == null) return false; + int existingLine = editor.getDocument().getLineNumber(highlighter.getStartOffset()) + 1; + // Match if highlighter covers the same PSI element region + return existingLine == line; + }); + } + + /** + * Gets the gutter icon for the given severity. + * + * @param severity the severity + * @return the severity icon + */ + public Icon getGutterIconBasedOnStatus(String severity) { + switch (SeverityLevel.fromValue(severity)) { + case MALICIOUS: + return CxIcons.Small.MALICIOUS; + case CRITICAL: + return CxIcons.Small.CRITICAL; + case HIGH: + return CxIcons.Small.HIGH; + case MEDIUM: + return CxIcons.Small.MEDIUM; + case LOW: + return CxIcons.Small.LOW; + case OK: + return CxIcons.Small.OK; + default: + return CxIcons.Small.UNKNOWN; + } + } + + /** + * Determines the highlighter layer for a specific scan detail. + * + * @param scanIssue the scan detail + * @return the highlighter layer + */ + public Integer determineHighlighterLayer(ScanIssue scanIssue) { + return severityHighlighterLayerMap.getOrDefault(scanIssue.getSeverity(), HighlighterLayer.WEAK_WARNING); + } + + /** + * Removes all existing gutter icons from the markup model in the given editor. + * + * @param project the file to remove the gutter icons from. + */ + public static void removeAllGutterIcons(Project project) { + try { + ApplicationManager.getApplication().invokeLater(() -> { + Editor editor = FileEditorManager.getInstance(project).getSelectedTextEditor(); + if (editor == null) return; + + MarkupModel markupModel = editor.getMarkupModel(); + if (markupModel.getAllHighlighters().length > 0) { + markupModel.removeAllHighlighters(); + } + }); + } catch (Exception e) { + LOGGER.debug("RTS-Decorator: Exception occurred while removing gutter icons for: {} ", + e.getMessage()); + } + } + + /** + * Restores problems for the given file. + * + * @param project the project + * @param psiFile the psi file + * @param scanIssueList the scan issue list + */ + public void restoreGutterIcons(Project project, PsiFile psiFile, List scanIssueList, Document document) { + removeAllGutterIcons(project); + for (ScanIssue scanIssue : scanIssueList) { + try { + int problemLineNumber = scanIssue.getLocations().get(0).getLine(); + PsiElement elementAtLine = DevAssistUtils.getPsiElement(psiFile, document, problemLineNumber); + if (Objects.isNull(elementAtLine)) { + LOGGER.debug("RTS-Decorator: Skipping to add gutter icon, Failed to find PSI element for line : {}", + problemLineNumber, scanIssue.getTitle()); + continue; + } + boolean isProblem = DevAssistUtils.isProblem(scanIssue.getSeverity().toLowerCase()); + highlightLineAddGutterIconForProblem(project, psiFile, scanIssue, isProblem, problemLineNumber); + } catch (Exception e) { + LOGGER.debug("RTS-Decorator: Exception occurred while restoring gutter icons for: {} ", + psiFile.getName(), scanIssue.getTitle(), e.getMessage()); + } + } + + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemHelper.java b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemHelper.java new file mode 100644 index 00000000..e3a87297 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemHelper.java @@ -0,0 +1,33 @@ +package com.checkmarx.intellij.devassist.problems; + +import com.checkmarx.intellij.devassist.basescanner.ScannerService; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.openapi.editor.Document; +import com.intellij.psi.PsiFile; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * Helper class for managing and creating problem descriptors. + */ +@AllArgsConstructor +@Getter +@Setter +@Builder +public class ProblemHelper { + + private final PsiFile file; + private final String filePath; + private final InspectionManager manager; + private final boolean isOnTheFly; + private final Document document; + private final List> supportedScanners; + private final ScanResult scanResult; + private final ProblemHolderService problemHolderService; + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemHolderService.java b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemHolderService.java new file mode 100644 index 00000000..1852848d --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/problems/ProblemHolderService.java @@ -0,0 +1,120 @@ +package com.checkmarx.intellij.devassist.problems; + +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import com.intellij.util.messages.Topic; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Service for managing problems and problem descriptors. + */ +@Service(Service.Level.PROJECT) +public final class ProblemHolderService { + // Scan issues for each file + private final Map> fileToIssues = new LinkedHashMap<>(); + + // Problem descriptors for each file to avoid display empty problems + private final Map> fileProblemDescriptor = new ConcurrentHashMap<>(); + + public static final Topic ISSUE_TOPIC = new Topic<>("ISSUES_UPDATED", IssueListener.class); + + public interface IssueListener { + void onIssuesUpdated(Map> issues); + } + + private final Project project; + + public ProblemHolderService(Project project) { + this.project = project; + } + + /** + * Returns the instance of this service for the given project. + * @param project the project. + * @return the instance of this service for the given project. + */ + public static ProblemHolderService getInstance(Project project) { + return project.getService(ProblemHolderService.class); + } + + /** + * Adds problems for the given file. + * @param filePath the file path. + * @param problems the scan issues. + */ + public synchronized void addProblems(String filePath, List problems) { + fileToIssues.put(filePath, new ArrayList<>(problems)); + // Notify subscribers immediately + project.getMessageBus().syncPublisher(ISSUE_TOPIC).onIssuesUpdated(getAllIssues()); + } + + public synchronized Map> getAllIssues() { + return Collections.unmodifiableMap(fileToIssues); + } + + public void removeAllProblemsOfType(String scannerType) { + for (Map.Entry> entry : getAllIssues().entrySet()) { + List problems = entry.getValue(); + if (problems != null) { + problems.removeIf(problem -> scannerType.equals(problem.getScanEngine().name())); + } + } + project.getMessageBus().syncPublisher(ISSUE_TOPIC).onIssuesUpdated(getAllIssues()); + } + + /** + * Returns the scan issues for the given file. + * @param filePath the file path. + * @return the scan issues. + */ + public synchronized List getScanIssueByFile(String filePath) { + return fileToIssues.getOrDefault(filePath, Collections.emptyList()); + } + + /** + * Returns the problem descriptors for the given file. + * @param filePath the file path. + * @return the problem descriptors. + */ + public List getProblemDescriptors(String filePath) { + return fileProblemDescriptor.getOrDefault(filePath, Collections.emptyList()); + } + + /** + * Adds problem descriptors for the given file. + * @param filePath the file path. + * @param problemDescriptors the problem descriptors. + */ + public void addProblemDescriptors(String filePath, List problemDescriptors) { + fileProblemDescriptor.put(filePath, new ArrayList<>(problemDescriptors)); + } + + /** + * Removes all problem descriptors for the given file. + * @param filePath the file path. + */ + public void removeProblemDescriptorsForFile(String filePath) { + fileProblemDescriptor.remove(filePath); + } + + /** + * Clears all problem descriptors. + */ + public void removeAllProblemDescriptors() { + fileProblemDescriptor.clear(); + } + + /** + * Adds problems to the CxOne findings for the given file. + * @param file the PSI file. + * @param problemsList the list of problems. + */ + public static void addToCxOneFindings(PsiFile file, List problemsList) { + getInstance(file.getProject()).addProblems(file.getVirtualFile().getPath(), problemsList); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/problems/ScanIssueProcessor.java b/src/main/java/com/checkmarx/intellij/devassist/problems/ScanIssueProcessor.java new file mode 100644 index 00000000..78d41aad --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/problems/ScanIssueProcessor.java @@ -0,0 +1,120 @@ +package com.checkmarx.intellij.devassist.problems; + +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * Helper class responsible for processing individual scan issues and creating problem descriptors. + * This class encapsulates the logic for validating, processing, and highlighting scan issues. + */ +@RequiredArgsConstructor +public class ScanIssueProcessor { + + private static final Logger LOGGER = Logger.getInstance(ScanIssueProcessor.class); + + private final ProblemDecorator problemDecorator; + private final PsiFile file; + private final InspectionManager manager; + private final Document document; + private final boolean isOnTheFly; + + public ScanIssueProcessor(ProblemHelper problemHelper, ProblemDecorator problemDecorator) { + this.problemDecorator = problemDecorator; + this.file = problemHelper.getFile(); + this.manager = problemHelper.getManager(); + this.document = problemHelper.getDocument(); + this.isOnTheFly = problemHelper.isOnTheFly(); + } + + /** + * Processes a single scan issue and returns a problem descriptor if applicable. + * + * @param scanIssue the scan issue to process + * @return a ProblemDescriptor if the issue is valid and should be reported, null otherwise + */ + public ProblemDescriptor processScanIssue(@NotNull ScanIssue scanIssue) { + if (!isValidLocation(scanIssue)) { + LOGGER.debug("RTS: Scan issue does not have location: {}", scanIssue.getTitle()); + return null; + } + int problemLineNumber = scanIssue.getLocations().get(0).getLine(); + + if (!isValidLineAndSeverity(problemLineNumber, scanIssue)) { + LOGGER.debug("RTS: Invalid Issue, it does not contains valid line: {} or severity: {} ", problemLineNumber, scanIssue.getSeverity()); + return null; + } + try { + return processValidIssue(scanIssue, problemLineNumber); + } catch (Exception e) { + LOGGER.error("RTS: Exception occurred while processing scan issue: {}, Exception: {}", scanIssue.getTitle(), e.getMessage()); + return null; + } + } + + /** + * Validates that the scan issue has valid locations. + */ + private boolean isValidLocation(ScanIssue scanIssue) { + return scanIssue.getLocations() != null && !scanIssue.getLocations().isEmpty(); + } + + /** + * Validates the line number and severity of the scan issue. + */ + private boolean isValidLineAndSeverity(int lineNumber, ScanIssue scanIssue) { + if (DevAssistUtils.isLineOutOfRange(lineNumber, document)) { + return false; + } + return scanIssue.getSeverity() != null && !scanIssue.getSeverity().isBlank(); + } + + /** + * Processes a valid scan issue, creates problem descriptor and adds gutter icon. + */ + private ProblemDescriptor processValidIssue(ScanIssue scanIssue, int problemLineNumber) { + boolean isProblem = DevAssistUtils.isProblem(scanIssue.getSeverity().toLowerCase()); + + ProblemDescriptor problemDescriptor = null; + if (isProblem) { + problemDescriptor = createProblemDescriptor(scanIssue, problemLineNumber); + } + highlightIssueIfNeeded(scanIssue, problemLineNumber, isProblem); + return problemDescriptor; + } + + /** + * Creates a problem descriptor for the given scan issue. + */ + private ProblemDescriptor createProblemDescriptor(ScanIssue scanIssue, int problemLineNumber) { + try { + return ProblemBuilder.build(file, manager, scanIssue, document, problemLineNumber, isOnTheFly); + } catch (Exception e) { + LOGGER.error("RTS: Failed to create problem descriptor for: {} ", scanIssue.getTitle(), e.getMessage()); + return null; + } + } + + /** + * Highlights the issue line and adds a gutter icon if a valid PSI element exists. + */ + private void highlightIssueIfNeeded(ScanIssue scanIssue, int problemLineNumber, boolean isProblem) { + PsiElement elementAtLine = DevAssistUtils.getPsiElement(file, document, problemLineNumber); + if (Objects.isNull(elementAtLine)) { + LOGGER.debug("RTS: Skipping to add gutter icon, Failed to find PSI element for line : {}", problemLineNumber, scanIssue.getTitle()); + return; + } + problemDecorator.highlightLineAddGutterIconForProblem( + file.getProject(), file, scanIssue, isProblem, problemLineNumber + ); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/registry/ScannerRegistry.java b/src/main/java/com/checkmarx/intellij/devassist/registry/ScannerRegistry.java new file mode 100644 index 00000000..2f94fba1 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/registry/ScannerRegistry.java @@ -0,0 +1,126 @@ +package com.checkmarx.intellij.devassist.registry; + +import com.checkmarx.intellij.devassist.scanners.oss.OssScannerCommand; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.openapi.Disposable; +import com.checkmarx.intellij.devassist.basescanner.ScannerCommand; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Disposer; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Project-level service whichKeeps a registry keyed by scanner ID, + * wires them up to the IntelliJ disposer, + * and exposes helpers for registering/de-registering scanners on demand. + */ +@Service(Service.Level.PROJECT) +public final class ScannerRegistry implements Disposable { + + private final Map scannerMap = new ConcurrentHashMap<>(); + + @Getter + private final Project project; + + /** + * Stores the project reference, registers this registry for disposal with the project, + * and initializes all supported scanners. + * + * @param project current IntelliJ project + */ + public ScannerRegistry( @NotNull Project project){ + this.project=project; + Disposer.register(this,project); + scannerInitialization(); + } + + /** + * Populates the registry with every scanner the plugin currently supports. + * New scanners should be added here to be available project-wide. + */ + private void scannerInitialization(){ + this.setScanner(ScanEngine.OSS.name(), new OssScannerCommand(this,project)); + } + + /** + * Adds a scanner implementation under the given ID and ensures it will be + * disposed when the registry itself is disposed. + * + * @param id unique scanner identifier + * @param scanner scanner command implementation + */ + private void setScanner(String id, ScannerCommand scanner){ + Disposer.register(this, scanner); + this.scannerMap.put(id,scanner); + } + + /** + * Registers all known scanners with the provided project so they can start listening + * for IDE events immediately. + * + * @param project target project for scanner registration + */ + + public void registerAllScanners(Project project){ + scannerMap.values().forEach(scanner->scanner.register(project)); + } + + /** + * De-registers every scanner from the stored project, effectively stopping + * all realtime scanning activity. + */ + public void deregisterAllScanners(){ + scannerMap.values().forEach(scanner->scanner.deregister(project)); + } + + /** + * Registers a single scanner identified by ID, if it exists in the registry. + * + * @param scannerId scanner identifier + */ + public void registerScanner(String scannerId){ + ScannerCommand scanner= getScanner(scannerId); + if(scanner!=null) scanner.register(project); + } + + + /** + * De-registers and disposes a single scanner identified by ID, if present. + * + * @param scannerId scanner identifier + */ + public void deregisterScanner(String scannerId){ + ScannerCommand scanner= getScanner(scannerId); + if(scanner!=null){ + scanner.deregister(project); + scanner.dispose(); + } + } + + /** + * Retrieves the scanner command registered under the given ID. + * + * @param scannerId scanner identifier + * @return scanner command instance or {@code null} when not found + */ + + public ScannerCommand getScanner(String scannerId){ + return this.scannerMap.get(scannerId); + } + + + /** + * Cleans up the registry by de-registering every scanner and clearing the map. + * Invoked automatically when the IntelliJ disposer tears down the service. + */ + @Override + public void dispose() { + this.deregisterAllScanners(); + scannerMap.clear(); + } + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/CxOneAssistFix.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/CxOneAssistFix.java new file mode 100644 index 00000000..57b29172 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/CxOneAssistFix.java @@ -0,0 +1,83 @@ +package com.checkmarx.intellij.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +import static java.lang.String.format; + +/** + * The `CxOneAssistFix` class implements the `LocalQuickFix` interface and provides a specific fix + * for issues detected during scans. This class is used to apply a remediation action + * related to a particular scan issue identified by the scanning engine. + *

+ * The class leverages a `ScanIssue` object, which encapsulates details of the issue, including + * its title, severity, and other relevant diagnostic information. + *

+ * Functionality of the class includes: + * - Defining a family name that represents the type of fix. + * - Providing an implementation to apply the fix within a given project in response to a specific problem descriptor. + *

+ * This fix is categorized under the "Fix with CXOne Assist" family for easy identification and grouping. + */ +public class CxOneAssistFix implements LocalQuickFix, Iconable { + + private static final Logger LOGGER = Utils.getLogger(CxOneAssistFix.class); + + @Getter + @SafeFieldForPreview + private final ScanIssue scanIssue; + + /** + * Constructs a CxOneAssistFix instance to provide a remediation action for the specified scan issue. + * This fix is used to address issues identified during a scan. + * + * @param scanIssue the scan issue that this fix targets; includes details such as severity, title, and description + */ + public CxOneAssistFix(ScanIssue scanIssue) { + super(); + this.scanIssue = scanIssue; + } + + /** + * Returns the family name of the fix, which is used to categorize and identify + * this fix within the scope of available remediation actions. + * + * @return a non-null string representing the family name of the fix + */ + @NotNull + @Override + public String getFamilyName() { + return Constants.RealTimeConstants.FIX_WITH_CXONE_ASSIST; + } + + /** + * Returns the icon representing this quick fix. + */ + @Override + public Icon getIcon(int flags) { + return CxIcons.STAR_ACTION; + } + + /** + * Applies a fix for a specified problem descriptor within a project. + * + * @param project the project where the fix is to be applied + * @param descriptor the problem descriptor that represents the issue to be fixed + */ + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + LOGGER.info(format("RTS-Fix: Remediation called: %s for issue: %s", getFamilyName(), scanIssue.getTitle())); + new RemediationManager().fixWithCxOneAssist(project, scanIssue); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/IgnoreAllThisTypeFix.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/IgnoreAllThisTypeFix.java new file mode 100644 index 00000000..bef9b4a8 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/IgnoreAllThisTypeFix.java @@ -0,0 +1,76 @@ +package com.checkmarx.intellij.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +/** + * A quick fix implementation to ignore all issues of a specific type during real-time scanning. + * This class provides mechanisms to group and apply fixes for particular types of scan issues. + * It implements the {@link LocalQuickFix} interface, which allows the integration of this fix + * with IntelliJ's inspection framework. + *

+ * The main functionality includes: + * - Providing a family name for grouping similar quick fixes. + * - Applying the fix to ignore all instances of the specified issue type. + *

+ * This class relies on the {@link ScanIssue} object that contains details about the specific issue to ignore. + * The fix is categorized using the family name provided by `Constants.RealTimeConstants.IGNORE_ALL_OF_THIS_TYPE_FIX_NAME`. + *

+ * It is expected that the scan issue passed at the time of object creation includes enough + * details to handle the ignoring process properly. + */ +public class IgnoreAllThisTypeFix implements LocalQuickFix, Iconable { + + private static final Logger LOGGER = Utils.getLogger(IgnoreAllThisTypeFix.class); + + @SafeFieldForPreview + private final ScanIssue scanIssue; + + public IgnoreAllThisTypeFix(ScanIssue scanIssue) { + this.scanIssue = scanIssue; + } + + /** + * Returns the family name of this quick fix. + * The family name is used to group similar quick fixes together and is displayed + * in the "Apply Fix" popup when multiple quick fixes are available. + * + * @return a non-null string representing the family name, which categorizes this quick fix + */ + @Override + public @IntentionFamilyName @NotNull String getFamilyName() { + return Constants.RealTimeConstants.IGNORE_ALL_OF_THIS_TYPE_FIX_NAME; + } + + /** + * Returns the icon representing this quick fix. + */ + @Override + public Icon getIcon(int flags) { + return CxIcons.STAR_ACTION; + } + + /** + * Applies a quick fix for a specified problem descriptor within a project. + * This method is invoked when the user selects this quick fix action to resolve + * an associated issue. + * + * @param project the project where the fix is to be applied; must not be null + * @param descriptor the problem descriptor that represents the issue to be fixed; must not be null + */ + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + LOGGER.info("applyFix called.." + getFamilyName() + " " + scanIssue.getTitle()); + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/IgnoreVulnerabilityFix.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/IgnoreVulnerabilityFix.java new file mode 100644 index 00000000..9d77c0eb --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/IgnoreVulnerabilityFix.java @@ -0,0 +1,74 @@ +package com.checkmarx.intellij.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +/** + * Represents a quick fix action that allows users to ignore a specific scan issue identified during a security scan. + * This class is part of the LocalQuickFix interface and provides functionality to mark a vulnerability as ignored + * within the context of the problem descriptor and the associated project. + */ +public class IgnoreVulnerabilityFix implements LocalQuickFix, Iconable { + + private static final Logger LOGGER = Utils.getLogger(IgnoreVulnerabilityFix.class); + + @SafeFieldForPreview + private final ScanIssue scanIssue; + + /** + * Constructs an IgnoreVulnerabilityFix instance to provide an action for ignoring a specific scan issue. + * This fix allows users to mark a vulnerability as ignored within the context of a scan. + * + * @param scanIssue the scan issue that this fix targets; contains details such as severity, title, description, + * file path, and associated vulnerabilities + */ + public IgnoreVulnerabilityFix(ScanIssue scanIssue) { + this.scanIssue = scanIssue; + } + + /** + * Returns the family name of this quick fix. + * The family name is used to group similar quick fixes together and is displayed + * in the "Apply Fix" popup when multiple quick fixes are available. + * + * @return a non-null string representing the family name, which categorizes this quick fix + */ + @Override + public @IntentionFamilyName @NotNull String getFamilyName() { + return Constants.RealTimeConstants.IGNORE_THIS_VULNERABILITY_FIX_NAME; + } + + /** + * Returns the icon representing this quick fix. + */ + @Override + public Icon getIcon(int flags) { + return CxIcons.STAR_ACTION; + } + + /** + * Applies a fix for a specified problem descriptor within a project. + * This method is implemented as part of the LocalQuickFix interface and performs + * the action associated with resolving or addressing the scan issue represented + * by the ProblemDescriptor. + * + * @param project the project in which the fix is to be applied; must not be null + * @param descriptor the descriptor representing the problem to be fixed; must not be null + */ + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + LOGGER.info("applyFix called.." + getFamilyName() + " " + scanIssue.getTitle()); + } + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/RemediationManager.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/RemediationManager.java new file mode 100644 index 00000000..78f2d489 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/RemediationManager.java @@ -0,0 +1,117 @@ +package com.checkmarx.intellij.devassist.remediation; + +import com.checkmarx.intellij.Bundle; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.prompts.CxOneAssistFixPrompts; +import com.checkmarx.intellij.devassist.remediation.prompts.ViewDetailsPrompts; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +import static java.lang.String.format; + +/** + * RemediationManager provides remediation options for issues identified during a real-time scan. + *

+ * This class supports applying fixes, viewing details etc for scan issues detected by different scan engines, + * such as OSS, ASCA, etc. It interacts with IntelliJ IDEA's project context and uses utility classes + * for logging, clipboard operations, and prompt generation. + *

+ * Main responsibilities: + *

    + *
  • Apply remediation for different scan engine issues
  • + *
  • Generate and copy remediation prompts to the clipboard
  • + *
  • Log remediation actions
  • + *
+ */ +public final class RemediationManager { + + private static final Logger LOGGER = Utils.getLogger(RemediationManager.class); + private static final String CX_AGENT_NAME = Constants.RealTimeConstants.CX_AGENT_NAME; + + /** + * Apply remediation for a given scan issue. + * + * @param project the project where the fix is to be applied + * @param scanIssue the scan issue to fix + */ + public void fixWithCxOneAssist(@NotNull Project project, @NotNull ScanIssue scanIssue) { + switch (scanIssue.getScanEngine()) { + case OSS: + applyOSSRemediation(project, scanIssue); + break; + case ASCA: + applyASCARemediation(project, scanIssue); + break; + default: + break; + } + } + + /** + * View details for a given scan issue. + * + * @param project the project where the fix is to be applied + * @param scanIssue the scan issue to view details for + */ + public void viewDetails(@NotNull Project project, @NotNull ScanIssue scanIssue) { + switch (scanIssue.getScanEngine()) { + case OSS: + explainOSSDetails(project, scanIssue); + break; + case ASCA: + applyASCARemediation(project, scanIssue); + break; + default: + break; + } + } + + /** + * Applies remediation for an OSS issue. + */ + private void applyOSSRemediation(Project project, ScanIssue scanIssue) { + LOGGER.info(format("RTS-Fix: Remediation started for file: %s for OSS Issue: %s", + scanIssue.getFilePath(), scanIssue.getTitle())); + String scaPrompt = CxOneAssistFixPrompts.scaRemediationPrompt(scanIssue.getTitle(), scanIssue.getPackageVersion(), + scanIssue.getPackageManager(), scanIssue.getSeverity()); + if (DevAssistUtils.copyToClipboardWithNotification(scaPrompt, CX_AGENT_NAME, + Bundle.message(Resource.DEV_ASSIST_COPY_FIX_PROMPT), project)) { + LOGGER.info(format("RTS-Fix: Remediation completed for file: %s for OSS Issue: %s", + scanIssue.getFilePath(), scanIssue.getTitle())); + } + } + + /** + * Applies remediation for an ASCA issue. + * + * @param scanIssue the scan issue to fix + */ + private void applyASCARemediation(Project project, ScanIssue scanIssue) { + LOGGER.info(format("RTS-Fix: Remediation started for file: %s for ASCA Issue: %s", + scanIssue.getFilePath(), scanIssue.getTitle())); + } + + /** + * Explain the details of an OSS issue. + * + * @param project the project where the fix is to be applied + * @param scanIssue the scan issue to view details for + */ + private void explainOSSDetails(Project project, ScanIssue scanIssue) { + LOGGER.info(format("RTS-Fix: Viewing details for file: %s for OSS Issue: %s", scanIssue.getFilePath(), scanIssue.getTitle())); + String scaPrompt = ViewDetailsPrompts.generateSCAExplanationPrompt(scanIssue.getTitle(), + scanIssue.getPackageVersion(), + scanIssue.getSeverity(), + scanIssue.getVulnerabilities()); + if (DevAssistUtils.copyToClipboardWithNotification(scaPrompt, CX_AGENT_NAME, + Bundle.message(Resource.DEV_ASSIST_COPY_VIEW_DETAILS_PROMPT), project)) { + LOGGER.info(format("RTS-Fix: Viewing details completed for file: %s for OSS Issue: %s", + scanIssue.getFilePath(), scanIssue.getTitle())); + } + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/ViewDetailsFix.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/ViewDetailsFix.java new file mode 100644 index 00000000..6e4ced7b --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/ViewDetailsFix.java @@ -0,0 +1,83 @@ +package com.checkmarx.intellij.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.util.IntentionFamilyName; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; + +import static java.lang.String.format; + +/** + * A class representing a quick fix that enables users to view details of a scan issue detected during + * a scanning process. This class implements the `LocalQuickFix` interface, allowing it to be presented + * as a fix option within IDE inspections or problem lists. + *

+ * This quick fix is primarily used to group and categorize similar fixes with a common family + * name, and to invoke functionality that provides further details about the associated scan issue. + *

+ * Key behaviors of this class include: + * - Providing a family name that categorizes this type of quick fix. + * - Implementing an action to be executed when the quick fix is applied, which in this case is to display details of the scan issue. + */ +public class ViewDetailsFix implements LocalQuickFix, Iconable { + + private static final Logger LOGGER = Utils.getLogger(ViewDetailsFix.class); + + @SafeFieldForPreview + private final ScanIssue scanIssue; + + /** + * Constructs a ViewDetailsFix instance to enable users to view details of the provided scan issue. + * This quick fix allows users to inspect detailed information about a specific issue identified + * during a scanning process. + * + * @param scanIssue the scan issue that this fix targets; includes details such as severity, title, description, locations, and vulnerabilities + */ + public ViewDetailsFix(ScanIssue scanIssue) { + super(); + this.scanIssue = scanIssue; + } + + /** + * Returns the family name of this quick fix. + * The family name is used to group similar quick fixes together and is displayed + * in the "Apply Fix" popup when multiple quick fixes are available. + * + * @return a non-null string representing the family name, which categorizes this quick fix + */ + @Override + public @IntentionFamilyName @NotNull String getFamilyName() { + return Constants.RealTimeConstants.VIEW_DETAILS_FIX_NAME; + } + + /** + * Returns the icon representing this quick fix. + */ + @Override + public Icon getIcon(int flags) { + return CxIcons.STAR_ACTION; + } + + /** + * Applies the quick fix action for the specified problem descriptor within the given project. + * This implementation displays details about the scan issue associated with this fix + * + * @param project the project where the fix is to be applied; must not be null + * @param descriptor the problem descriptor that represents the issue to be fixed; must not be null + */ + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + LOGGER.info(format("RTS-Fix: Remediation called: %s for issue: %s", getFamilyName(), scanIssue.getTitle())); + new RemediationManager().viewDetails(project, scanIssue); + } + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/prompts/CxOneAssistFixPrompts.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/prompts/CxOneAssistFixPrompts.java new file mode 100644 index 00000000..6ae50f79 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/prompts/CxOneAssistFixPrompts.java @@ -0,0 +1,242 @@ +package com.checkmarx.intellij.devassist.remediation.prompts; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.util.SeverityLevel; + +/** + * Checkmarx prompts for various remediation tasks. + */ +public final class CxOneAssistFixPrompts { + + private static final String AGENT_NAME = Constants.RealTimeConstants.CX_AGENT_NAME; + + private CxOneAssistFixPrompts() { + throw new IllegalStateException("Cannot instantiate CxOneAssistFixPrompts class"); + } + + /** + * Builds the SCA remediation prompt (generic concatenated form). + * + * @param packageName vulnerable package name (e.g. "node-ipc") + * @param packageVersion vulnerable package version (e.g. "10.1.1") + * @param packageManager ecosystem / package manager (e.g. "npm", "maven") + * @param severity textual severity (e.g. "Malicious", "High") + * @return composed prompt string (plain text with Markdown fragments) + */ + public static String scaRemediationPrompt(String packageName, String packageVersion, + String packageManager, String severity) { + StringBuilder prompt = new StringBuilder(); + + prompt.append("You are the ").append(AGENT_NAME).append(".\n\n") + .append("A security issue has been detected in `").append(packageName).append("@").append(packageVersion).append("` (package manager: `").append(packageManager).append("`).\n") + .append("**Severity:** `").append(severity).append("`\n") + .append("Your task is to remediate the issue **completely and autonomously** using the internal PackageRemediation tool in Checkmarx MCP. Follow the exact instructions in `fix_instructions` — no assumptions or manual interaction allowed.\n\n"); + + prompt.append("Step 1. ANALYSIS (AUTOMATED):\n\n") + .append("Determine the issue type:\n") + .append("- If `status` is one of: `Critical`, `High`, `Medium`, `Low`, `Info`, set: `issueType = \"CVE\"`\n") + .append("- If `status = \"Malicious\"`, set: `issueType = \"malicious\"`\n\n") + .append("Call the internal PackageRemediation tool with:\n\n") + .append("```json\n") + .append("{\n") + .append(" \"packageName\": \"").append(packageName).append("\",\n") + .append(" \"packageVersion\": \"").append(packageVersion).append("\",\n") + .append(" \"packageManager\": \"").append(packageManager).append("\",\n") + .append(" \"issueType\": \"{determined issueType}\"\n") + .append("}\n") + .append("```\n\n"); + + prompt.append("Parse the response and extract the `fix_instructions` field. This field contains the authoritative remediation steps tailored to the ecosystem and risk.\n\n"); + + prompt.append("Step 2. EXECUTION (AUTOMATED):\n\n") + .append("- Read and execute each line in `fix_instructions`, in order.\n") + .append("- For each change:\n") + .append(" - Apply the instruction exactly.\n") + .append(" - Track all modified files.\n") + .append(" - Note the type of change (e.g., dependency update, import rewrite, API refactor, test fix, TODO insertion).\n") + .append(" - Record before → after values where applicable.\n") + .append(" - Capture line numbers if known.\n") + .append("Examples:\n") + .append("- `package.json`: lodash version changed from 3.10.1 → 4.17.21\n") + .append("- `src/utils/date.ts`: import updated from `lodash` to `date-fns`\n") + .append("- `src/main.ts:42`: `_.pluck(users, 'id')` → `users.map(u => u.id)`\n") + .append("- `src/index.ts:78`: // TODO: Verify API migration from old-package to new-package\n\n"); + + prompt.append("Step 3. VERIFICATION:\n\n") + .append("- If the instructions include build, test, or audit steps — run them exactly as written\n") + .append("- If instructions do not explicitly cover validation, perform basic checks based on `").append(packageManager).append("`:\n") + .append(" - `npm`: `npx tsc --noEmit`, `npm run build`, `npm test`\n") + .append(" - `go`: `go build ./...`, `go test ./...`\n") + .append(" - `maven`: `mvn compile`, `mvn test`\n") + .append(" - `pypi`: `python -c \"import ").append(packageName).append("\"`, `pytest`\n") + .append(" - `nuget`: `dotnet build`, `dotnet test`\n\n") + + .append("If any of these validations fail:\n\n") + .append("- Attempt to fix the issue if it's obvious\n") + .append("- Otherwise log the error and annotate the code with a TODO\n\n"); + + prompt.append("Step 4. OUTPUT:\n\n").append("Prefix all output with: `").append(AGENT_NAME).append(" -`\n\n"); + + prompt.append("✅ **Remediation Summary**\n\n") + .append("Format:\n") + .append("```\n") + .append("Security Assistant - Remediation Summary\n\n") + .append("Package: ").append(packageName).append("\n") + .append("Version: ").append(packageVersion).append("\n") + .append("Manager: ").append(packageManager).append("\n") + .append("Severity: ").append(severity).append("\n\n") + .append("Files Modified:\n") + .append("1. package.json\n") + .append(" - Updated dependency: lodash 3.10.1 → 4.17.21\n\n") + .append("2. src/utils/date.ts\n") + .append(" - Updated import: from 'lodash' to 'date-fns'\n") + .append(" - Replaced usage: _.pluck(users, 'id') → users.map(u => u.id)\n\n") + .append("3. src/__tests__/date.test.ts\n") + .append(" - Fixed test: adjusted mock expectations to match updated API\n\n") + .append("4. src/index.ts\n") + .append(" - Line 78: Inserted TODO: Verify API migration from old-package to new-package\n") + .append("```\n\n"); + + prompt.append("✅ **Final Status**\n\n") + .append("If all tasks succeeded:\n\n") + .append("- \"Remediation completed for ").append(packageName).append("@").append(packageVersion).append("\"\n") + .append("- \"All fix instructions and failing tests resolved\"\n") + .append("- \"Build status: PASS\"\n") + .append("- \"Test results: PASS\"\n\n"); + + prompt.append("If partially resolved:\n\n") + .append("- \"Remediation partially completed – manual review required\"\n") + .append("- \"Some test failures or instructions could not be automatically fixed\"\n") + .append("- \"TODOs inserted where applicable\"\n\n"); + + prompt.append("If failed:\n\n") + .append("- \"Remediation failed for ").append(packageName).append("@").append(packageVersion).append("\"\n") + .append("- \"Reason: {summary of failure}\"\n") + .append("- \"Unresolved instructions or failing tests listed above\"\n\n"); + + prompt.append("Step 5. CONSTRAINTS:\n\n") + .append("- Do not the user\n") + .append("- Do not skip or reorder fix steps\n") + .append("- Only execute what's explicitly listed in `fix_instructions`\n") + .append("- Attempt to fix test failures automatically\n") + .append("- Insert clear TODO comments for unresolved issues\n") + .append("- Ensure remediation is deterministic, auditable, and fully automated"); + return prompt.toString(); + } + + + /** + * Generates a secret remediation prompt. + * + * @param title - issue title + * @param description - issue description (optional) - if null, will be empty string. + * @param severity - issue severity (optional) - if null, will be empty string. + * @return - prompt string (plain text with Markdown fragments) + */ + public static String generateSecretRemediationPrompt(String title, String description, String severity) { + StringBuilder prompt = new StringBuilder() + .append("A secret has been detected: \"").append(title).append("\" \n") + .append(description != null ? description : "").append("\n\n") + .append("---\n\n") + .append("You are the `").append(AGENT_NAME).append("`.\n\n") + .append("Your mission is to identify and remediate this secret using secure coding standards. Follow industry best practices, automate safely, and clearly document all actions taken.\n\n") + .append("---\n\n"); + + prompt.append("Step 1. SEVERITY INTERPRETATION \n") + .append("Severity level: `").append(severity != null ? severity : "").append("`\n\n") + .append("- `Critical`: Secret is confirmed **valid**. Immediate remediation required. \n") + .append("- `High`: Secret may be valid. Treat as sensitive and externalize it securely. \n") + .append("- `Medium`: Likely **invalid** (e.g., test or placeholder). Still remove from code and annotate accordingly.\n\n") + .append("---\n\n"); + + prompt.append("Step 2. TOOL CALL – Remediation Plan\n\n") + .append("Determine the programming language of the file where the secret was detected. \n") + .append("If unknown, leave the `language` field empty.\n\n") + .append("Call the internal `codeRemediation` Checkmarx MCP tool with:\n\n") + .append("```json\n") + .append("{\n") + .append(" \"type\": \"secret\",\n") + .append(" \"sub_type\": \"").append(title).append("\",\n") + .append(" \"language\": \"[auto-detected language]\"\n") + .append("}\n") + .append("```\n\n") + .append("- If the tool is **available**, parse the response:\n") + .append(" - `remediation_steps` – exact steps to follow\n") + .append(" - `best_practices` – explain secure alternatives\n") + .append(" - `description` – contextual background\n\n") + .append("- If the tool is **not available**, display:\n") + .append("`[MCP ERROR] codeRemediation tool is not available. Please check the Checkmarx MCP server.`\n\n") + .append("---\n\n"); + + prompt.append("Step 3. ANALYSIS & RISK\n\n") + .append("Identify the type of secret (API key, token, credential). Explain:\n") + .append("- Why it’s a risk (leakage, unauthorized access, compliance violations)\n") + .append("- What could happen if misused or left in source\n\n") + .append("---\n\n"); + + prompt.append("Step 4. REMEDIATION STRATEGY\n\n") + .append("- Parse and apply every item in `remediation_steps` sequentially\n") + .append("- Automatically update code/config files if safe\n") + .append("- If a step cannot be applied automatically, insert a clear TODO\n") + .append("- Replace secret with environment variable or vault reference\n\n") + .append("---\n\n"); + + prompt.append("Step 5. VERIFICATION\n\n") + .append("If applicable for the language:\n") + .append("- Run type checks or compile the code\n") + .append("- Ensure changes build and tests pass\n") + .append("- Fix issues if introduced by secret removal\n\n") + .append("---\n\n"); + + prompt.append("Step 6. OUTPUT FORMAT\n\n") + .append("Generate a structured remediation summary:\n\n") + .append("```markdown\n") + .append("### ").append(AGENT_NAME).append(" - Secret Remediation Summary\n\n") + .append("**Secret:** ").append(title).append(" \n") + .append("**Severity:** ").append(severity != null ? severity : "").append(" \n") + .append("**Assessment:** ").append(getAssessmentText(severity)).append("\n\n") + .append("**Files Modified:**\n") + .append("- `.env`: Added/updated with `SECRET_NAME`\n") + .append("- `src/config.ts`: Replaced hardcoded secret with `process.env.SECRET_NAME`\n\n") + .append("**Remediation Actions Taken:**\n") + .append("- ✅ Removed hardcoded secret\n") + .append("- ✅ Inserted environment reference\n") + .append("- ✅ Updated or created .env\n") + .append("- ✅ Added TODOs for secret rotation or vault storage\n\n") + .append("**Next Steps:**\n") + .append("- [ ] Revoke exposed secret (if applicable)\n") + .append("- [ ] Store securely in vault (AWS Secrets Manager, GitHub Actions, etc.)\n") + .append("- [ ] Add CI/CD secret scanning\n\n") + .append("**Best Practices:**\n") + .append("- (From tool response, or fallback security guidelines)\n\n") + .append("**Description:**\n") + .append("- (From `description` field or fallback to original input)\n\n") + .append("```\n\n") + .append("---\n\n"); + + prompt.append("Step 7. CONSTRAINTS\n\n") + .append("- ❌ Do NOT expose real secrets\n") + .append("- ❌ Do NOT generate fake-looking secrets\n") + .append("- ✅ Follow only what’s explicitly returned from MCP\n") + .append("- ✅ Use secure externalization patterns\n") + .append("- ✅ Respect OWASP, NIST, and GitHub best practices\n"); + return prompt.toString(); + } + + /** + * Generates the assessment text for given severity. + * + * @param severity severity level + * @return assessment text + */ + private static String getAssessmentText(String severity) { + if (SeverityLevel.CRITICAL.getSeverity().equalsIgnoreCase(severity)) { + return "✅ Confirmed valid secret. Immediate remediation performed."; + } else if (SeverityLevel.HIGH.getSeverity().equalsIgnoreCase(severity)) { + return "⚠️ Possibly valid. Handled as sensitive."; + } else { + return "ℹ️ Likely invalid (test/fake). Removed for hygiene."; + } + } + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/remediation/prompts/ViewDetailsPrompts.java b/src/main/java/com/checkmarx/intellij/devassist/remediation/prompts/ViewDetailsPrompts.java new file mode 100644 index 00000000..d00ac6ac --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/remediation/prompts/ViewDetailsPrompts.java @@ -0,0 +1,125 @@ +package com.checkmarx.intellij.devassist.remediation.prompts; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.devassist.model.Vulnerability; +import com.checkmarx.intellij.util.SeverityLevel; + +import java.util.List; + +/** + * Prompt builder for generating prompts for viewing vulnerability details. + */ +public final class ViewDetailsPrompts { + + private ViewDetailsPrompts() { + throw new IllegalStateException("Cannot instantiate ViewDetailsPrompts class"); + } + + private static final String AGENT_NAME = Constants.RealTimeConstants.CX_AGENT_NAME; + + /** + * Generates a detailed prompt for explaining the security issue of an SCA/OSS. + * + * @param packageName The name of the software package. + * @param version The version of the software package. + * @param status The security status of the package (e.g., "malicious", "vulnerable"). + * @param vulnerabilities A list of vulnerabilities affecting the package. + * @return A formatted prompt string for explaining the security issue. + */ + public static String generateSCAExplanationPrompt(String packageName, String version, + String status, List vulnerabilities) { + StringBuilder prompt = new StringBuilder(); + prompt.append("You are the `").append(AGENT_NAME).append("`.\n\n") + .append("Your task is to **analyze and explain** the security issue affecting the package `") + .append(packageName).append("@").append(version).append("` with status: `").append(status).append("`.\n\n"); + + prompt.append("### ❗ Important Instructions:\n") + .append("- 👉 **Do not change anything in the code. Just explain the risks and remediation steps.**\n") + .append("- ❌ **Never include references from Checkmarx competitors.**\n\n"); + + prompt.append("### 🔍 Package Overview\n\n") + .append("- **Package:** `").append(packageName).append("`\n") + .append("- **Version:** `").append(version).append("`\n") + .append("- **Status:** `").append(status).append("`\n"); + + if (status.equalsIgnoreCase(SeverityLevel.MALICIOUS.getSeverity())) { + buildMaliciousContentForSCAPrompt(version, prompt); + } else { + buildVulnerabilityContentForSCAPrompt(status, vulnerabilities, prompt); + } + prompt.append("### 🛠️ Remediation Guidance\n\n") + .append("Offer actionable advice:\n") + .append("- Whether to remove, upgrade, or replace the package\n") + .append("- If malicious: clearly emphasize permanent removal\n") + .append("- Recommend safer, verified alternatives if available\n") + .append("- Suggest preventative measures:\n") + .append(" - Use SCA in CI/CD\n") + .append(" - Prefer signed packages\n") + .append(" - Pin versions to prevent shadow updates\n\n"); + + prompt.append("### ✅ Summary Section\n\n") + .append("Conclude with:\n") + .append("- Overall risk explanation\n") + .append("- Immediate remediation steps\n") + .append("- Whether this specific version is linked to online reports\n") + .append("- If not, reference Checkmarx attribution (per above rules)\n") + .append("- Never mention competitor vendors or tools\n\n"); + + prompt.append("### ✏️ Output Formatting\n\n") + .append("- Use Markdown: `##`, `- `, `**bold**`, `code`\n") + .append("- Developer-friendly tone, informative, concise\n") + .append("- No speculation — use only trusted, verified sources\n"); + + return prompt.toString(); + } + + /** + * Builds a prompt for explaining malicious packages. + * + * @param version the version of the package + * @param prompt the prompt builder + */ + private static void buildMaliciousContentForSCAPrompt(String version, StringBuilder prompt) { + prompt.append("\n---\n\n") + .append("### 🧨 Malicious Package Detected\n\n") + .append("This package has been flagged as **malicious**.\n\n") + .append("**⚠️ Never install or use this package under any circumstances.**\n\n") + .append("#### 🔎 Web Investigation:\n\n") + .append("- Search the web for trusted community or vendor reports about malicious activity involving this package.\n") + .append("- If information exists about other versions but **not** version `").append(version).append("`, explicitly say:\n\n") + .append("> _“This specific version (`").append(version).append("`) was identified as malicious by Checkmarx Security researchers.”_\n\n") + .append("- If **no credible external information is found at all**, state:\n\n") + .append("> _“This package was identified as malicious by Checkmarx Security researchers based on internal threat intelligence and behavioral analysis.”_\n\n") + .append("Then explain:\n") + .append("- What types of malicious behavior these packages typically include (e.g., data exfiltration, postinstall backdoors)\n") + .append("- Indicators of compromise developers should look for (e.g., suspicious scripts, obfuscation, DNS calls)\n\n") + .append("**Recommended Actions:**\n") + .append("- ✅ Immediately remove from all codebases and pipelines\n") + .append("- ❌ Never reinstall or trust any version of this package\n") + .append("- 🔁 Replace with a well-known, secure alternative\n") + .append("- 🔒 Consider running a retrospective security scan if this was installed\n\n"); + } + + /** + * Builds a prompt for explaining known vulnerabilities. + * + * @param status the severity status of the package + * @param vulnerabilities the list of vulnerabilities affecting the package + * @param prompt the prompt builder + */ + private static void buildVulnerabilityContentForSCAPrompt(String status, List vulnerabilities, StringBuilder prompt) { + prompt.append("### 🚨 Known Vulnerabilities\n\n") + .append("Explain each known CVE affecting this package:\n"); + + if (vulnerabilities != null && !vulnerabilities.isEmpty()) { + for (int i = 0; i < vulnerabilities.size(); i++) { + Vulnerability vuln = vulnerabilities.get(i); + prompt.append("\n#### ").append(i + 1).append(". ").append(vuln.getCve()).append("\n") + .append("- **Severity:** ").append(vuln.getSeverity()).append("\n") + .append("- **Description:** ").append(vuln.getDescription()).append("\n"); + } + } else { + prompt.append("\n⚠️ No CVEs were provided. Please verify if this is expected for status `").append(status).append("`.\n"); + } + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScanResultAdaptor.java b/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScanResultAdaptor.java new file mode 100644 index 00000000..9d4a5899 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScanResultAdaptor.java @@ -0,0 +1,138 @@ +package com.checkmarx.intellij.devassist.scanners.oss; + +import com.checkmarx.ast.ossrealtime.OssRealtimeResults; +import com.checkmarx.ast.ossrealtime.OssRealtimeScanPackage; +import com.checkmarx.ast.ossrealtime.OssRealtimeVulnerability; +import com.checkmarx.ast.realtime.RealtimeLocation; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.model.Location; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.model.Vulnerability; +import com.checkmarx.intellij.devassist.utils.ScanEngine; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Adapter class for handling OSS scan results and converting them into a standardized format + * using the {@link ScanResult} interface. + * This class wraps an {@code OssRealtimeResults} instance and provides methods to process and extract + * meaningful scan issues based on vulnerabilities detected in the packages. + */ +public class OssScanResultAdaptor implements ScanResult { + private final OssRealtimeResults ossRealtimeResults; + + /** + * Constructs an instance of {@code OssScanResultAdaptor} with the specified OSS real-time results. + * This adapter allows conversion and processing of OSS scan results into a standardized format. + * + * @param ossRealtimeResults the OSS real-time scan results to be wrapped by this adapter + */ + public OssScanResultAdaptor(OssRealtimeResults ossRealtimeResults) { + this.ossRealtimeResults = ossRealtimeResults; + } + + /** + * Retrieves the OSS real-time scan results wrapped by this adapter. + * + * @return an {@code OssRealtimeResults} instance containing the results of the OSS scan + */ + @Override + public OssRealtimeResults getResults() { + return ossRealtimeResults; + } + + /** + * Retrieves a list of scan issues discovered in the OSS real-time scan. + * This method processes the packages obtained from the scan results, + * converts them into standardized scan issues, and returns the list. + * If no packages are found, an empty list is returned. + * + * @return a list of {@code ScanIssue} objects representing the vulnerabilities found during the scan, + * or an empty list if no vulnerabilities are detected. + */ + @Override + public List getIssues() { + List packages = Objects.nonNull(getResults()) ? getResults().getPackages() : null; + if (Objects.isNull(packages) || packages.isEmpty()) { + return Collections.emptyList(); + } + return packages.stream() + .map(this::createScanIssue) + .collect(Collectors.toList()); + } + + /** + * Creates a {@code ScanIssue} object based on the provided {@code OssRealtimeScanPackage}. + * The method processes the package details and converts them into a structured format to + * represent a scan issue. + * + * @param packageObj the {@code OssRealtimeScanPackage} containing information about the scanned package, + * including its name, version, vulnerabilities, and locations. + * @return a {@code ScanIssue} object encapsulating the details such as title, package version, scan engine, + * severity, and vulnerability locations derived from the provided package. + */ + private ScanIssue createScanIssue(OssRealtimeScanPackage packageObj) { + ScanIssue scanIssue = new ScanIssue(); + + scanIssue.setPackageManager(packageObj.getPackageManager()); + scanIssue.setTitle(packageObj.getPackageName()); + scanIssue.setPackageVersion(packageObj.getPackageVersion()); + scanIssue.setScanEngine(ScanEngine.OSS); + scanIssue.setSeverity(packageObj.getStatus()); + scanIssue.setFilePath(packageObj.getFilePath()); + + if (Objects.nonNull(packageObj.getLocations()) && !packageObj.getLocations().isEmpty()) { + packageObj.getLocations().forEach(location -> + scanIssue.getLocations().add(createLocation(location))); + } + if (packageObj.getVulnerabilities() != null && !packageObj.getVulnerabilities().isEmpty()) { + packageObj.getVulnerabilities().forEach(vulnerability -> + scanIssue.getVulnerabilities().add(createVulnerability(vulnerability))); + } + return scanIssue; + } + + /** + * Creates a {@code Vulnerability} instance based on the provided {@code OssRealtimeVulnerability}. + * This method extracts relevant information such as the ID, description, severity, and fix version + * from the provided {@code OssRealtimeVulnerability} object and uses it to construct a new + * {@code Vulnerability}. + * + * @param vulnerability the {@code OssRealtimeVulnerability} object containing details of the vulnerability + * identified during the real-time scan, including ID, description, severity, + * and fix version + * @return a new {@code Vulnerability} instance encapsulating the details from the given {@code OssRealtimeVulnerability} + */ + private Vulnerability createVulnerability(OssRealtimeVulnerability vulnerability) { + return new Vulnerability(vulnerability.getCve(), vulnerability.getDescription(), + vulnerability.getSeverity(), "", vulnerability.getFixVersion()); + } + + /** + * Creates a {@code Location} object based on the provided {@code RealtimeLocation}. + * This method extracts the line, start index, and end index from the given + * {@code RealtimeLocation} and constructs a new {@code Location} instance. + * + * @param location the {@code RealtimeLocation} containing details such as line, + * start index, and end index for the location. + * @return a new {@code Location} instance with the line incremented by one, + * and start and end indices derived from the provided {@code RealtimeLocation}. + */ + private Location createLocation(RealtimeLocation location) { + return new Location(getLine(location), location.getStartIndex(), location.getEndIndex()); + } + + /** + * Retrieves the line number from the given {@code RealtimeLocation} object, increments it by one, and returns the result. + * + * @param location the {@code RealtimeLocation} object containing the original line number + * @return the incremented line number based on the {@code RealtimeLocation}'s line value + * @apiNote - Current OSS scan result line numbers are zero-based, so this method adjusts them to be one-based. + */ + private int getLine(RealtimeLocation location) { + return location.getLine() + 1; + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScannerCommand.java b/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScannerCommand.java new file mode 100644 index 00000000..4e3bfd31 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScannerCommand.java @@ -0,0 +1,130 @@ +package com.checkmarx.intellij.devassist.scanners.oss; + +import com.checkmarx.intellij.Bundle; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.basescanner.BaseScannerCommand; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.intellij.notification.NotificationType; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ProjectRootManager; +import com.intellij.openapi.vfs.VfsUtilCore; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.FileSystems; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public class OssScannerCommand extends BaseScannerCommand { + public OssScannerService ossScannerService; + private final Project project; + private static final Logger LOGGER = Utils.getLogger(OssScannerCommand.class); + + private OssScannerCommand(@NotNull Disposable parentDisposable, @NotNull Project project, @NotNull OssScannerService OssscannerService) { + super(parentDisposable, OssScannerService.createConfig()); + this.ossScannerService = OssscannerService; + this.project = project; + } + + public OssScannerCommand(@NotNull Disposable parentDisposable, + @NotNull Project project) { + this(parentDisposable, project, new OssScannerService()); + } + + /** + * Initializes the scanner , invoked after registration of the scanner + */ + + @Override + protected void initializeScanner() { + new Task.Backgroundable(project, Bundle.message(Resource.STARTING_CHECKMARX_OSS_SCAN), false) { + @Override + public void run(@NotNull ProgressIndicator indicator){ + indicator.setIndeterminate(true); + indicator.setText(Bundle.message(Resource.STARTING_CHECKMARX_OSS_SCAN)); + scanAllManifestFilesInFolder(); + } + }.queue(); + } + + /** + * Scans all manifest Files in the opened project + * Happens on project opened or when scanner is enabled + * Iterates the recursively each file in the root expect node modules , if it matches the manifest files pattern + * appends the matchFiles path in List and triggers separate scan on each of them + */ + + private void scanAllManifestFilesInFolder() { + List matchedURIs = new ArrayList<>(); + + List pathMatchers = Constants.RealTimeConstants.MANIFEST_FILE_PATTERNS.stream() + .map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p)) + .collect(Collectors.toList()); + + for (VirtualFile vRoot : ProjectRootManager.getInstance(project).getContentRoots()) { + if(Objects.nonNull(vRoot)){ + VfsUtilCore.iterateChildrenRecursively(vRoot, null, file -> { + if (!file.isDirectory() && !file.getPath().contains("/node_modules/") && file.exists()) { + String path = file.getPath(); + for (PathMatcher matcher : pathMatchers) { + if (matcher.matches(Paths.get(path))) { + matchedURIs.add(path); + break; + } + } + } + return true; + }); + } + } + for (String uri : matchedURIs) { + Optional file = Optional.ofNullable(this.findVirtualFile(uri)); + if (file.isPresent()) { + try { + PsiFile psiFile = ReadAction.compute(()-> + PsiManager.getInstance(project).findFile(file.get())); + if (Objects.isNull(psiFile)) { + continue; + } + ScanResult ossRealtimeResults = ossScannerService.scan(psiFile, uri); + if (Objects.isNull(ossRealtimeResults)) { + LOGGER.warn("Scan failed for manifest file: " + uri); + continue; + } + ProblemHolderService.addToCxOneFindings(psiFile, ossRealtimeResults.getIssues()); + } catch (Exception e) { + LOGGER.warn("Scan failed for manifest file: " + uri + " with exception:" + e); + } + } + } + } + + /** + * Disposes the listeners automatically + * Triggered when project is closed + */ + + @Override + public void dispose() { + super.dispose(); + } + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScannerService.java b/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScannerService.java new file mode 100644 index 00000000..becfeef7 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/scanners/oss/OssScannerService.java @@ -0,0 +1,254 @@ +package com.checkmarx.intellij.devassist.scanners.oss; + +import com.checkmarx.ast.wrapper.CxException; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.basescanner.BaseScannerService; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.configuration.ScannerConfig; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.checkmarx.intellij.settings.global.CxWrapperFactory; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.psi.PsiFile; +import com.checkmarx.ast.ossrealtime.OssRealtimeResults; +import org.jetbrains.annotations.NotNull; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Realtime OSS manifest scanner Class that does temporary file handling, + * and invocation of the Checkmarx OSS realtime scanning engine. + */ +public class OssScannerService extends BaseScannerService { + private static final Logger LOGGER = Utils.getLogger(OssScannerService.class); + + /** + * Creates an OSS scanner service with the default OSS realtime configuration. + */ + public OssScannerService() { + super(createConfig()); + } + + + /** + * Builds the default scanner configuration used for OSS realtime scanning. + * + * @return fully populated {@link ScannerConfig} instance for the OSS engine + */ + public static ScannerConfig createConfig() { + return ScannerConfig.builder() + .engineName(ScanEngine.OSS.name()) + .configSection(Constants.RealTimeConstants.OSS_REALTIME_SCANNER) + .activateKey(Constants.RealTimeConstants.ACTIVATE_OSS_REALTIME_SCANNER) + .errorMessage(Constants.RealTimeConstants.ERROR_OSS_REALTIME_SCANNER) + .disabledMessage(Constants.RealTimeConstants.OSS_REALTIME_SCANNER_DISABLED) + .enabledMessage(Constants.RealTimeConstants.OSS_REALTIME_SCANNER_START) + .build(); + } + + + /** + * Determines whether a file should be scanned by validating the base checks and + * ensuring it matches a supported manifest pattern. + * + * @param filePath absolute path to the file + * @return {@code true} if the file should be scanned; {@code false} otherwise + */ + public boolean shouldScanFile(String filePath) { + if (!super.shouldScanFile(filePath)) { + return false; + } + return this.isManifestFilePatternMatching(filePath); + } + + + /** + * Checks whether the supplied file path matches any of the manifest glob patterns. + * + * @param filePath path to evaluate + * @return {@code true} if a manifest pattern matches; {@code false} otherwise + */ + private boolean isManifestFilePatternMatching(String filePath) { + List pathMatchers = Constants.RealTimeConstants.MANIFEST_FILE_PATTERNS.stream() + .map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p)) + .collect(Collectors.toList()); + for (PathMatcher pathMatcher : pathMatchers) { + if (pathMatcher.matches(Paths.get(filePath))) { + return true; + } + } + return false; + } + + /** + * Creates a deterministic, filesystem-safe file name for storing the manifest in the temp directory. + * + * @param relativePath path of the manifest relative to the project + * @return sanitized temp file name containing the original base name and a hash suffix + */ + private String toSafeTempFileName(@NotNull String relativePath) { + String baseName = Paths.get(relativePath).getFileName().toString(); + String hash = this.generateFileHash(relativePath); + return baseName + "-" + hash + ".tmp"; + } + + /** + * Generates a short hash based on the manifest path and the current time to avoid collisions. + * + * @param relativePath path whose value participates in the hash + * @return hexadecimal hash string suitable for filenames + */ + private String generateFileHash(@NotNull String relativePath) { + try { + LocalTime time = LocalTime.now(); + String timeSuffix = String.format("%02d%02d", time.getMinute(), time.getSecond()); + String combined = relativePath + timeSuffix + UUID.randomUUID().toString().substring(0,5); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(combined.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + hexString.append(String.format("%02x", b)); + } + return hexString.substring(0, 16); + } catch (NoSuchAlgorithmException e) { + LOGGER.debug("Using alternative method of generating hashCode for temporary file"); + return Integer.toHexString((relativePath + System.currentTimeMillis()).hashCode()); + } + } + + /** + * Resolves the temporary sub-folder path allocated to the supplied PSI file. + * + * @param file manifest PSI file being scanned + * @return path pointing to a unique temp directory per file + */ + protected Path getTempSubFolderPath(@NotNull PsiFile file) { + String baseTempPath = super.getTempSubFolderPath(Constants.RealTimeConstants.OSS_REALTIME_SCANNER_DIRECTORY); + String relativePath = file.getName(); + return Paths.get(baseTempPath, toSafeTempFileName(relativePath)); + } + + /** + * Persists the main manifest file into the temporary directory for scanning. + * + * @param tempSubFolder destination temp directory + * @param originalFilePath original manifest path (used for logging and file naming) + * @param file PSI file containing the manifest contents + * @return optional containing the path to the temp manifest file when saved successfully + * @throws IOException if writing the file fails + */ + private Optional saveMainManifestFile(Path tempSubFolder, @NotNull String originalFilePath, PsiFile file) throws IOException { + String fileText = DevAssistUtils.getFileContent(file); + + if (fileText == null || fileText.isBlank()) { + LOGGER.warn("No content found in file" + originalFilePath); + return Optional.empty(); + } + Path originalPath = Paths.get(originalFilePath); + String fileName = originalPath.getFileName().toString(); + Path tempFilePath = Paths.get(tempSubFolder.toString(), fileName); + Files.writeString(tempFilePath, fileText, StandardCharsets.UTF_8); + return Optional.of(tempFilePath.toString()); + } + + + /** + * Copies a companion lock file (e.g., package-lock.json) into the temporary directory + * when it exists alongside the scanned manifest. + * + * @param tempFolderPath temp directory where the companion file should be written + * @param originalFilePath original manifest path used to locate the companion file + */ + private void saveCompanionFile(Path tempFolderPath, String originalFilePath) { + if (originalFilePath.isEmpty() || Objects.isNull(tempFolderPath)) { + return; + } + String parentFileName = getPath(originalFilePath).getFileName().toString(); + String companionFileName = getCompanionFileName(parentFileName); + if (companionFileName.isEmpty()) { + return; + } + Path parentPath = getPath(originalFilePath).getParent(); + Path companionOriginalPath = Paths.get(parentPath.toString(), companionFileName); + if (!Files.exists(companionOriginalPath)) { + return; + } + Path companionTempPath = Paths.get(tempFolderPath.toString(), companionFileName); + try { + Files.copy(companionOriginalPath, companionTempPath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOGGER.warn("Error occurred while saving companion file: " + e); + } + } + + + /** + * Convenience wrapper for {@link Paths#get(String, String...)} to support testing or overrides. + * + * @param file path string to convert + * @return {@link Path} instance pointing to the supplied file + */ + private Path getPath(@NotNull String file) { + return Paths.get(file); + } + + + /** + * Infers a companion lock file name based on the manifest file name. + * + * @param fileName name of the manifest file + * @return companion file name or an empty string when no companion is defined + */ + private String getCompanionFileName(String fileName) { + if (fileName.equals("package.json")) { + return "package-lock.json"; + } + if (fileName.contains(".csproj")) { + return "package.lock.json"; + } + return ""; + } + + /** + * Scans the given Psi file using OssScanner wrapper method. + * + * @param file - the file to scan + * @param uri - the file path + * @return ScanResult of type OssRealtimeResults + */ + public ScanResult scan(@NotNull PsiFile file, @NotNull String uri) { + if (!this.shouldScanFile(uri)) { + return null; + } + Path tempSubFolder = this.getTempSubFolderPath(file); + try { + this.createTempFolder(tempSubFolder); + Optional mainTempPath = this.saveMainManifestFile(tempSubFolder, uri, file); + if (mainTempPath.isEmpty()) { + return null; + } + this.saveCompanionFile(tempSubFolder, uri); + LOGGER.info("Start Realtime Scan On File: " + uri); + OssRealtimeResults scanResults = CxWrapperFactory.build().ossRealtimeScan(mainTempPath.get(), ""); + return new OssScanResultAdaptor(scanResults); + + } catch (IOException | CxException | InterruptedException e) { + LOGGER.warn("Error occurred during OSS realTime scan", e); + } finally { + LOGGER.debug("Deleting temporary folder"); + deleteTempFolder(tempSubFolder); + } + return null; + } + +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/ui/ProblemDescription.java b/src/main/java/com/checkmarx/intellij/devassist/ui/ProblemDescription.java new file mode 100644 index 00000000..91ab2efa --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/ui/ProblemDescription.java @@ -0,0 +1,237 @@ +package com.checkmarx.intellij.devassist.ui; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.model.Vulnerability; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.util.SeverityLevel; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static com.checkmarx.intellij.Utils.escapeHtml; + +/** + * This class is responsible for handling and formatting descriptions of scan issues + * including their severity, associated vulnerabilities, and remediation guidance. + * It provides various utility methods to construct and format messages for + * different types of issues. + */ +public class ProblemDescription { + + private static final Map DESCRIPTION_ICON = new LinkedHashMap<>(); + + private static final String DIV = "

"; + private static final String COUNT = "COUNT"; + private static final String PACKAGE = "Package"; + private static final String DEV_ASSIST = "DevAssist"; + private static final String TITLE_FONT_FAMILY = "font-family: menlo;"; + private static final String TITLE_FONT_SIZE = "font-size:11px;"; + private static final String SECONDARY_COLOUR = "color:#ADADAD;"; + + public ProblemDescription() { + initIconsMap(); + } + + /** + * Initializes the mapping from severity levels to severity-specific icons. + */ + private static void initIconsMap() { + DESCRIPTION_ICON.put(SeverityLevel.MALICIOUS.getSeverity(), getImage(Constants.ImagePaths.MALICIOUS_PNG)); + DESCRIPTION_ICON.put(SeverityLevel.CRITICAL.getSeverity(), getImage(Constants.ImagePaths.CRITICAL_PNG)); + DESCRIPTION_ICON.put(SeverityLevel.HIGH.getSeverity(), getImage(Constants.ImagePaths.HIGH_PNG)); + DESCRIPTION_ICON.put(SeverityLevel.MEDIUM.getSeverity(), getImage(Constants.ImagePaths.MEDIUM_PNG)); + DESCRIPTION_ICON.put(SeverityLevel.LOW.getSeverity(), getImage(Constants.ImagePaths.LOW_PNG)); + + DESCRIPTION_ICON.put(getSeverityCountIconKey(SeverityLevel.CRITICAL.getSeverity()), getImage(Constants.ImagePaths.CRITICAL_16_PNG)); + DESCRIPTION_ICON.put(getSeverityCountIconKey(SeverityLevel.HIGH.getSeverity()), getImage(Constants.ImagePaths.HIGH_16_PNG)); + DESCRIPTION_ICON.put(getSeverityCountIconKey(SeverityLevel.MEDIUM.getSeverity()), getImage(Constants.ImagePaths.MEDIUM_16_PNG)); + DESCRIPTION_ICON.put(getSeverityCountIconKey(SeverityLevel.LOW.getSeverity()), getImage(Constants.ImagePaths.LOW_16_PNG)); + + DESCRIPTION_ICON.put(PACKAGE, getImage(Constants.ImagePaths.PACKAGE_PNG)); + DESCRIPTION_ICON.put(DEV_ASSIST, getImage(Constants.ImagePaths.DEV_ASSIST_PNG)); + } + + /** + * Reloads the mapping from severity levels to severity-specific icons. + */ + public static void reloadIcons() { + initIconsMap(); + } + + /** + * Formats a description for the given scan issue, incorporating details such as + * relevant icon, scan engine information, and issue details in an HTML structure. + * Depending on the scan engine type, it delegates the construction of the specific + * description sections to corresponding helper methods. + * + * @param scanIssue the ScanIssue object containing information about the identified issue, + * including details like its severity, title, vulnerabilities, and scan engine. + * @return a String representing the formatted HTML description of the scan issue, + * with visual elements included for improved readability. + */ + public String formatDescription(ScanIssue scanIssue) { + + StringBuilder descBuilder = new StringBuilder(); + descBuilder.append("
") + .append(DIV).append("
") + .append(DESCRIPTION_ICON.get(DEV_ASSIST)).append("
"); + switch (scanIssue.getScanEngine()) { + case OSS: + buildOSSDescription(descBuilder, scanIssue); + break; + case ASCA: + buildASCADescription(descBuilder, scanIssue); + break; + default: + buildDefaultDescription(descBuilder, scanIssue); + } + descBuilder.append("
"); + return descBuilder.toString(); + } + + /** + * Builds the OSS description for the provided scan issue and appends it to the given StringBuilder. + * This method incorporates severity-specific formatting, including handling for malicious packages, + * and assembles the description with the package header and vulnerability details. + * + * @param descBuilder the StringBuilder to which the formatted OSS description will be appended + * @param scanIssue the ScanIssue object containing information about the scanned issue, + * including its severity, vulnerabilities, and related details + */ + private void buildOSSDescription(StringBuilder descBuilder, ScanIssue scanIssue) { + buildPackageMessage(descBuilder, scanIssue); + buildVulnerabilitySection(descBuilder, scanIssue); + } + + /** + * Builds the ASCA description for the provided scan issue and appends it to the given StringBuilder. + * This method formats details about the scan issue, including its title, remediation advice, + * and scanning engine information. + * + * @param descBuilder the StringBuilder to which the formatted ASCA description will be appended + * @param scanIssue the ScanIssue object containing details about the issue, such as its title, + * remediation advice, and the scanning engine responsible for detecting the issue + */ + private void buildASCADescription(StringBuilder descBuilder, ScanIssue scanIssue) { + descBuilder.append("") + .append(""); + descBuilder.append("
") + .append("").append(escapeHtml(scanIssue.getTitle())).append(" - ") + .append(escapeHtml(scanIssue.getRemediationAdvise())).append(" - ") + .append(scanIssue.getScanEngine().name()) + .append("
") + .append(getIcon(scanIssue.getSeverity())) + .append("

"); + } + + /** + * Returns the icon path for the specified key. + * + * @param key the key for the icon path + * @return the icon path + */ + private String getIcon(String key) { + return DESCRIPTION_ICON.getOrDefault(key, ""); + } + + /** + * Builds the default description for a scan issue and appends it to the provided StringBuilder. + * This method formats basic details about the scan issue, including its title and description. + * + * @param descBuilder the StringBuilder to which the formatted default description will be appended + * @param scanIssue the ScanIssue object containing details about the issue such as title and description + */ + private void buildDefaultDescription(StringBuilder descBuilder, ScanIssue scanIssue) { + descBuilder.append("
").append(scanIssue.getTitle()).append(" -").append(scanIssue.getDescription()); + } + + /** + * Builds the package header section of a description for a scan issue and appends it to the provided StringBuilder. + * This method formats information about the scan issue's severity, title, and package version, + * and includes an associated image icon representing the issue. + * + * @param descBuilder the StringBuilder to which the formatted package header information will be appended + * @param scanIssue the ScanIssue object containing details about the issue such as severity, title, and package version + */ + private void buildPackageMessage(StringBuilder descBuilder, ScanIssue scanIssue) { + String secondaryText = Constants.RealTimeConstants.SEVERITY_PACKAGE; + String icon = getIcon(PACKAGE); + if (scanIssue.getSeverity().equalsIgnoreCase(SeverityLevel.MALICIOUS.getSeverity())) { + secondaryText = PACKAGE; + icon = getIcon(scanIssue.getSeverity()); + } + descBuilder.append("") + .append("") + .append("
") + .append(icon).append("").append(scanIssue.getTitle()).append("@") + .append(scanIssue.getPackageVersion()).append(" - ") + .append(scanIssue.getSeverity()).append(" ").append(secondaryText) + .append("
"); + } + + /** + * Builds the vulnerability section of a scan issue description and appends it to the provided StringBuilder. + * This method processes the list of vulnerabilities associated with the scan issue, categorizes them by severity, + * and includes detailed descriptions for specific vulnerabilities where applicable. + * + * @param descBuilder the StringBuilder to which the formatted vulnerability section will be appended + * @param scanIssue the ScanIssue object containing details about the scan, including associated vulnerabilities + */ + private void buildVulnerabilitySection(StringBuilder descBuilder, ScanIssue scanIssue) { + List vulnerabilityList = scanIssue.getVulnerabilities(); + if(Objects.isNull(vulnerabilityList) || vulnerabilityList.isEmpty()) { + return; + } + descBuilder.append(DIV).append(""); + Map vulnerabilityCount = getVulnerabilityCount(vulnerabilityList); + DESCRIPTION_ICON.forEach((severity, iconPath) -> { + Long count = vulnerabilityCount.get(severity); + if (count != null && count > 0) { + descBuilder.append("") + .append(""); + } + }); + descBuilder.append("
").append(getIcon(getSeverityCountIconKey(severity))).append("") + .append(count).append("
"); + } + + /** + * Calculates the count of vulnerabilities grouped by their severity levels. + * This method processes a list of vulnerabilities, retrieves their severity, + * and returns a map where the keys are severity levels and the values are the counts. + * + * @param vulnerabilityList the list of vulnerabilities to be grouped and counted by severity + * @return a map where the key is the severity level and the value is the count of vulnerabilities at that severity + */ + private Map getVulnerabilityCount(List vulnerabilityList) { + return vulnerabilityList.stream() + .map(Vulnerability::getSeverity) + .collect(Collectors.groupingBy(severity -> severity, Collectors.counting())); + } + + /** + * Generates an HTML image element based on the provided icon name. + * + * @param iconPath the path to the image file that will be used in the HTML content + * @return a String representing an HTML image element with the provided icon path + */ + private static String getImage(String iconPath) { + // Add vertical-align:middle and remove default spacing; display:inline-block ensures tight layout. + return iconPath.isEmpty() ? "" : ""; + } + + /** + * Returns the key for the icon representing the specified severity with a count suffix. + * + * @param severity the severity + * @return the key for the icon representing the specified severity with a count suffix + */ + private static String getSeverityCountIconKey(String severity) { + return severity + COUNT; + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/ui/WelcomeDialog.java b/src/main/java/com/checkmarx/intellij/devassist/ui/WelcomeDialog.java new file mode 100644 index 00000000..7b56c629 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/ui/WelcomeDialog.java @@ -0,0 +1,312 @@ +package com.checkmarx.intellij.devassist.ui; + +import com.checkmarx.intellij.Bundle; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.settings.SettingsListener; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.ui.ColorUtil; +import com.intellij.ui.JBColor; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.JBUI; +import com.intellij.util.ui.UIUtil; +import lombok.Getter; +import net.miginfocom.swing.MigLayout; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.awt.*; + +/** + * Welcome dialog displayed after successful authentication. + * Presents plugin features and allows enabling/disabling real-time scanners when MCP is available. + * Real-time settings are abstracted via {@link RealTimeSettingsManager} for testability. + */ +public class WelcomeDialog extends DialogWrapper { + + private static final int WRAP_WIDTH = 250; + private static final Dimension PREFERRED_DIALOG_SIZE = new Dimension(720, 460); + private static final Dimension RIGHT_PANEL_SIZE = new Dimension(420, 420); + private static final int PANEL_SPACING = 20; + + private final boolean mcpEnabled; + private final RealTimeSettingsManager settingsManager; + + @Getter + private JBCheckBox realTimeScannersCheckbox; + + public WelcomeDialog(@Nullable Project project, boolean mcpEnabled) { + this(project, mcpEnabled, new DefaultRealTimeSettingsManager()); + } + + /** + * Constructor with dependency injection for testability. + * + * @param project current project (nullable) + * @param mcpEnabled whether MCP is enabled for this tenant + * @param settingsManager wrapper around settings reads/writes + */ + public WelcomeDialog(@Nullable Project project, boolean mcpEnabled, RealTimeSettingsManager settingsManager) { + super(project, false); + this.mcpEnabled = mcpEnabled; + this.settingsManager = settingsManager; + setOKButtonText(Bundle.message(Resource.WELCOME_CLOSE_BUTTON)); + init(); + setTitle("Checkmarx"); + getRootPane().setPreferredSize(PREFERRED_DIALOG_SIZE); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + JPanel centerPanel = new JPanel(new BorderLayout()); + centerPanel.add(createLeftPanel(), BorderLayout.WEST); + centerPanel.add(createRightImagePanel(), BorderLayout.CENTER); + return centerPanel; + } + + /** + * Builds the left-side content area: title, subtitle, feature card and main bullets. + */ + private JComponent createLeftPanel() { + JPanel leftPanel = new JPanel(new MigLayout("insets 20 20 20 20, gapy 10, wrap 1", "[grow]")); + + // Title + JBLabel title = new JBLabel(Bundle.message(Resource.WELCOME_TITLE)); + title.setFont(title.getFont().deriveFont(Font.BOLD, 24f)); + leftPanel.add(title, "gapbottom 4"); + + // Subtitle wrapped to a fixed width for consistent layout + JBLabel subtitle = new JBLabel("
" + + Bundle.message(Resource.WELCOME_SUBTITLE) + "
"); + subtitle.setForeground(UIUtil.getLabelForeground()); + leftPanel.add(subtitle); + + // Assist feature card + leftPanel.add(createFeatureCard(), "gapbottom 8"); + + // Main bullets + leftPanel.add(createBullet(Resource.WELCOME_MAIN_FEATURE_1)); + leftPanel.add(createBullet(Resource.WELCOME_MAIN_FEATURE_2)); + leftPanel.add(createBullet(Resource.WELCOME_MAIN_FEATURE_3)); + leftPanel.add(createBullet(Resource.WELCOME_MAIN_FEATURE_4), "gapbottom 8"); + + // MCP-specific controls + if (mcpEnabled) { + initializeRealtimeState(); + } + configureCheckboxBehavior(); + refreshCheckboxState(); + return leftPanel; + } + + /** + * A simple card with a header (includes the MCP toggle when available) and feature bullets. + */ + private JComponent createFeatureCard() { + JPanel featureCard = new JPanel(new MigLayout("insets 10, gapy 4, wrap 1", "[grow]", "[]push[]")); + featureCard.setBorder(BorderFactory.createLineBorder(JBColor.border())); + + // Subtle, theme-aware background differing slightly from the dialog panel + Color base = UIUtil.getPanelBackground(); + Color subtleBg = JBColor.isBright() ? ColorUtil.darker(base, 1) : ColorUtil.brighter(base, 1); + featureCard.setOpaque(true); + featureCard.setBackground(subtleBg); + + featureCard.add(createFeatureCardHeader(subtleBg), "growx"); + featureCard.add(createFeatureCardBullets(), "growx"); + return featureCard; + } + + private JComponent createFeatureCardHeader(Color backgroundColor) { + JPanel header = new JPanel(new MigLayout("insets 0, gapx 6", "[][grow]")); + header.setOpaque(false); + realTimeScannersCheckbox = new JBCheckBox(); + realTimeScannersCheckbox.setEnabled(mcpEnabled); + realTimeScannersCheckbox.setOpaque(false); + realTimeScannersCheckbox.setContentAreaFilled(false); + realTimeScannersCheckbox.setBackground(backgroundColor); + header.add(realTimeScannersCheckbox); + JBLabel assistTitle = new JBLabel(Bundle.message(Resource.WELCOME_ASSIST_TITLE)); + assistTitle.setFont(assistTitle.getFont().deriveFont(Font.BOLD)); + header.add(assistTitle, "growx, pushx"); + return header; + } + + private JComponent createFeatureCardBullets() { + JPanel bulletsPanel = new JPanel(new MigLayout("insets 0, wrap 1", "[grow]")); + bulletsPanel.setOpaque(false); + bulletsPanel.add(createBullet(Resource.WELCOME_ASSIST_FEATURE_1)); + bulletsPanel.add(createBullet(Resource.WELCOME_ASSIST_FEATURE_2)); + bulletsPanel.add(createBullet(Resource.WELCOME_ASSIST_FEATURE_3)); + if (mcpEnabled) { + bulletsPanel.add(createBullet(Resource.WELCOME_MCP_INSTALLED_INFO)); + } else { + // Show a theme-aware MCP disabled info icon + JBLabel mcpDisabledIcon = new JBLabel(CxIcons.getWelcomeMcpDisableIcon()); + mcpDisabledIcon.setHorizontalAlignment(SwingConstants.CENTER); + bulletsPanel.add(mcpDisabledIcon, "growx, wrap"); + } + return bulletsPanel; + } + + // Builds the right-side panel that hosts an image + + private JComponent createRightImagePanel() { + JPanel rightPanel = new JPanel(new BorderLayout()); + rightPanel.setBorder(JBUI.Borders.empty(PANEL_SPACING)); + rightPanel.setPreferredSize(RIGHT_PANEL_SIZE); + rightPanel.setMinimumSize(RIGHT_PANEL_SIZE); + rightPanel.setMaximumSize(RIGHT_PANEL_SIZE); + + // Load the original icon + Icon originalIcon = CxIcons.getWelcomeScannerIcon(); + JBLabel imageLabel = new JBLabel(originalIcon); + imageLabel.setHorizontalAlignment(SwingConstants.CENTER); + imageLabel.setVerticalAlignment(SwingConstants.TOP); + rightPanel.add(imageLabel, BorderLayout.NORTH); + + return rightPanel; + } + + @Override + protected Action[] createActions() { + Action okAction = getOKAction(); + okAction.putValue(DEFAULT_ACTION, Boolean.TRUE); + return new Action[]{okAction}; + } + + @Override + protected JComponent createSouthPanel() { + JComponent southPanel = super.createSouthPanel(); + if (southPanel != null) { + southPanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, JBColor.border()), + JBUI.Borders.empty(12, 16, 0, 16) + )); + } + return southPanel; + } + + /** + * Wires the MCP checkbox to update all real-time flags via the settings manager. + */ + private void configureCheckboxBehavior() { + if (realTimeScannersCheckbox == null) return; + realTimeScannersCheckbox.addActionListener(e -> { + settingsManager.setAll(realTimeScannersCheckbox.isSelected()); + refreshCheckboxState(); + }); + } + + /** + * Initializes the realtime scanner state with intelligent preference handling. + * For new users: enables all scanners as defaults when MCP is active + * For existing users: preserves their individual scanner preferences + * This ensures user choice is respected while providing sensible defaults for new users. + */ + private void initializeRealtimeState() { + if (!mcpEnabled) { + return; // No scanner configuration needed when MCP is disabled + } + + GlobalSettingsState state = GlobalSettingsState.getInstance(); + boolean allEnabled = settingsManager.areAllEnabled(); + boolean hasCustomPrefs = state.hasCustomUserPreferences(); + + // Only modify settings for new users who need sensible defaults + if (!allEnabled && !hasCustomPrefs) { + // New user with MCP enabled - provide the convenient "all enabled" default + settingsManager.setAll(true); + } + // For existing users, their preferences have already been restored by the authentication flow + } + + /** + * Syncs the MCP checkbox UI state with current settings. + */ + private void refreshCheckboxState() { + if (realTimeScannersCheckbox == null) return; + realTimeScannersCheckbox.setSelected(settingsManager.areAllEnabled()); + updateCheckboxTooltip(); + } + + /** + * Updates the checkbox tooltip based on the current state and MCP availability. + * Shows the appropriate enable/disable message when MCP is enabled, shows MCP not enabled message when MCP is disabled. + */ + private void updateCheckboxTooltip() { + if (realTimeScannersCheckbox == null) { + return; + } + + if (!mcpEnabled) { + realTimeScannersCheckbox.setToolTipText("Checkmarx MCP is not enabled for this tenant."); + return; + } + + String tooltipText = realTimeScannersCheckbox.isSelected() + ? "Disable all real-time scanners" + : "Enable all real-time scanners"; + realTimeScannersCheckbox.setToolTipText(tooltipText); + } + + /** + * Builds a single bullet row with a glyph and a wrapped text label. + */ + public JComponent createBullet(Resource res) { + JPanel panel = new JPanel(new MigLayout("insets 0, gapx 6, fillx", "[][grow, fill]")); + panel.setOpaque(false); + + JBLabel glyph = new JBLabel("\u2022"); + glyph.setFont(new Font("Dialog", Font.BOLD, glyph.getFont().getSize())); + + JBLabel text = new JBLabel("
" + + Bundle.message(res) + "
"); + + panel.add(glyph, "top"); + panel.add(text, "growx"); + return panel; + } + + /** + * Abstraction over real-time settings to allow testing. + */ + public interface RealTimeSettingsManager { + boolean areAllEnabled(); + void setAll(boolean enable); + } + + /** + * Default production implementation backed by {@link GlobalSettingsState}. + * Handles both active scanner state and user preference persistence. + */ + private static class DefaultRealTimeSettingsManager implements RealTimeSettingsManager { + @Override + public boolean areAllEnabled() { + GlobalSettingsState s = GlobalSettingsState.getInstance(); + return s.isOssRealtime() && s.isSecretDetectionRealtime() && s.isContainersRealtime() && s.isIacRealtime(); + } + + @Override + public void setAll(boolean enable) { + GlobalSettingsState s = GlobalSettingsState.getInstance(); + + // Update active scanner states + s.setOssRealtime(enable); + s.setSecretDetectionRealtime(enable); + s.setContainersRealtime(enable); + s.setIacRealtime(enable); + s.setUserPreferences(enable, enable, enable, enable); + + // Persist changes and notify listeners + GlobalSettingsState.getInstance().apply(s); + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(SettingsListener.SETTINGS_APPLIED) + .settingsApplied(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/devassist/ui/actions/VulnerabilityFilterBaseAction.java b/src/main/java/com/checkmarx/intellij/devassist/ui/actions/VulnerabilityFilterBaseAction.java new file mode 100644 index 00000000..f73d1c85 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/ui/actions/VulnerabilityFilterBaseAction.java @@ -0,0 +1,124 @@ +package com.checkmarx.intellij.devassist.ui.actions; + +import com.checkmarx.intellij.tool.window.Severity; +import com.checkmarx.intellij.tool.window.actions.filter.Filterable; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.ToggleAction; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.util.messages.MessageBus; +import com.intellij.util.messages.Topic; +import org.jetbrains.annotations.NotNull; + +import java.util.Set; + +/** + * Base toggle action for severity filters in CxFindingsWindow + */ +public abstract class VulnerabilityFilterBaseAction extends ToggleAction { + + public static final Topic TOPIC = Topic.create("Vulnerability Filter Changed", VulnerabilityFilterChanged.class); + + protected final MessageBus messageBus = ApplicationManager.getApplication().getMessageBus(); + protected final Filterable filterable; + + public VulnerabilityFilterBaseAction() { + super(); + filterable = getFilterable(); + getTemplatePresentation().setText(filterable.tooltipSupplier()); + getTemplatePresentation().setIcon(filterable.getIcon()); + } + + @Override + public boolean isSelected(@NotNull AnActionEvent e) { + Set filters = VulnerabilityFilterState.getInstance().getFilters(); + return filters.contains(filterable); + } + + @Override + public void setSelected(@NotNull AnActionEvent e, boolean state) { + Set filters = VulnerabilityFilterState.getInstance().getFilters(); + if (state) { + filters.add(filterable); + } else { + filters.remove(filterable); + } + messageBus.syncPublisher(TOPIC).filterChanged(); + } + + protected abstract Filterable getFilterable(); + + public static class VulnerabilityMaliciousFilter extends VulnerabilityFilterBaseAction { + + public VulnerabilityMaliciousFilter() { + super(); + } + + @Override + protected Filterable getFilterable() { + return Severity.MALICIOUS; + } + } + + public static class VulnerabilityCriticalFilter extends VulnerabilityFilterBaseAction { + + public VulnerabilityCriticalFilter() { + super(); + } + + @Override + protected Filterable getFilterable() { + return Severity.CRITICAL; + } + } + + public static class VulnerabilityHighFilter extends VulnerabilityFilterBaseAction { + + public VulnerabilityHighFilter() { + super(); + } + + @Override + protected Filterable getFilterable() { + return Severity.HIGH; + } + } + + public static class VulnerabilityMediumFilter extends VulnerabilityFilterBaseAction { + + public VulnerabilityMediumFilter() { + super(); + } + + @Override + protected Filterable getFilterable() { + return Severity.MEDIUM; + } + } + + public static class VulnerabilityLowFilter extends VulnerabilityFilterBaseAction { + + public VulnerabilityLowFilter() { + super(); + } + + @Override + protected Filterable getFilterable() { + return Severity.LOW; + } + } + + /** + * Interface for topic {@link VulnerabilityFilterBaseAction#TOPIC}. + */ + public interface VulnerabilityFilterChanged { + + void filterChanged(); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } +} + diff --git a/src/main/java/com/checkmarx/intellij/devassist/ui/actions/VulnerabilityFilterState.java b/src/main/java/com/checkmarx/intellij/devassist/ui/actions/VulnerabilityFilterState.java new file mode 100644 index 00000000..00da2b7f --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/ui/actions/VulnerabilityFilterState.java @@ -0,0 +1,37 @@ +package com.checkmarx.intellij.devassist.ui.actions; + +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.checkmarx.intellij.tool.window.actions.filter.Filterable; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Holds filter state (set of Filterables) for the CxFindingsWindow tab. + */ +public class VulnerabilityFilterState { + private static final VulnerabilityFilterState INSTANCE = new VulnerabilityFilterState(); + + private final Set selectedFilters = Collections.synchronizedSet(new HashSet<>()); + + private VulnerabilityFilterState() { + // Initialize selectedFilters with global default filters on first load + Set globalDefaults = GlobalSettingsState.getInstance().getFilters(); + if (selectedFilters.isEmpty()) { + selectedFilters.addAll(globalDefaults); + } + } + + + public static VulnerabilityFilterState getInstance() { + return INSTANCE; + } + + public Set getFilters() { + if (selectedFilters.isEmpty()) { + selectedFilters.addAll(GlobalSettingsState.getInstance().getFilters()); + } + return selectedFilters; + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/devassist/ui/findings/window/CxFindingsWindow.java b/src/main/java/com/checkmarx/intellij/devassist/ui/findings/window/CxFindingsWindow.java new file mode 100644 index 00000000..56b0e413 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/ui/findings/window/CxFindingsWindow.java @@ -0,0 +1,536 @@ +package com.checkmarx.intellij.devassist.ui.findings.window; + +import com.checkmarx.intellij.*; +import com.checkmarx.intellij.devassist.model.Location; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.remediation.RemediationManager; +import com.checkmarx.intellij.devassist.ui.actions.VulnerabilityFilterBaseAction; +import com.checkmarx.intellij.devassist.ui.actions.VulnerabilityFilterState; +import com.checkmarx.intellij.settings.SettingsListener; +import com.checkmarx.intellij.settings.global.GlobalSettingsComponent; +import com.checkmarx.intellij.settings.global.GlobalSettingsConfigurable; +import com.checkmarx.intellij.tool.window.actions.filter.Filterable; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.checkmarx.intellij.Constants; +import com.intellij.ide.DataManager; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.actionSystem.*; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.*; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.options.ShowSettingsUtil; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.SimpleToolWindowPanel; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.util.Iconable; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.ui.Gray; +import com.intellij.ui.JBColor; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBScrollPane; +import com.intellij.ui.content.Content; +import com.intellij.ui.treeStructure.SimpleTree; +import com.intellij.uiDesigner.core.GridConstraints; +import com.intellij.uiDesigner.core.GridLayoutManager; +import com.intellij.util.ui.JBUI; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.Timer; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.List; +import java.util.stream.Collectors; + + +/** + * Handles drawing of the Checkmarx vulnerability tool window. + * Extends {@link SimpleToolWindowPanel} to provide a panel with toolbar and content area. + * Implements {@link Disposable} for cleanup toolbar actions. + * Manages a tree view of vulnerabilities with filtering and navigation capabilities. + * Initializes icons for different vulnerability severities. + * Subscribes to settings changes and problem updates to refresh the UI accordingly. + * Uses a timer to periodically update the tab title with the current problem count. + * Refactored to have separate drawAuthPanel() and drawMainPanel() following pattern in CxToolWindowPanel. + */ +public class CxFindingsWindow extends SimpleToolWindowPanel + implements Disposable { + + private static final Logger LOGGER = Utils.getLogger(CxFindingsWindow.class); + + private final Project project; + private final SimpleTree tree; + private final DefaultMutableTreeNode rootNode; + private static Map vulnerabilityCountToIcon; + private static Map vulnerabilityToIcon; + private static Set expandedPathsSet = new HashSet<>(); + private final Content content; + private final Timer timer; + + private final RemediationManager remediationManager = new RemediationManager(); + + public CxFindingsWindow(Project project, Content content) { + super(false, true); + this.project = project; + this.tree = new SimpleTree(); + this.rootNode = new DefaultMutableTreeNode(); + this.content = content; + + // Setup initial UI based on settings validity, subscribe to settings changes + Runnable settingsCheckRunnable = () -> { + if (new GlobalSettingsComponent().isValid()) { + drawMainPanel(); + } else { + drawAuthPanel(); + } + }; + + project.getMessageBus().connect(this) + .subscribe(VulnerabilityFilterBaseAction.TOPIC, + (VulnerabilityFilterBaseAction.VulnerabilityFilterChanged) () -> ApplicationManager.getApplication().invokeLater(this::triggerRefreshTree)); + + ApplicationManager.getApplication().getMessageBus() + .connect(this) + .subscribe(SettingsListener.SETTINGS_APPLIED, settingsCheckRunnable::run); + + settingsCheckRunnable.run(); + + LOGGER.debug("Initiated the custom problem window for project: " + project.getName()); + + // Initialize icons for rendering + initVulnerabilityCountIcons(); + initVulnerabilityIcons(); + + // Setup tree model and renderer + tree.setModel(new DefaultTreeModel(rootNode)); + tree.setCellRenderer(new IssueTreeRenderer(tree, vulnerabilityToIcon, vulnerabilityCountToIcon)); + tree.setRootVisible(false); + + // Add mouse listeners for navigation and popup menu + tree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 1) + navigateToSelectedIssue(); + } + + @Override + public void mousePressed(MouseEvent e) { + handleRightClick(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + handleRightClick(e); + } + }); + + // Timer for updating tab title count + timer = new Timer(1000, e -> updateTabTitle()); + timer.start(); + Disposer.register(this, () -> timer.stop()); + + // Trigger initial refresh with existing scan results if any (on EDT) + SwingUtilities.invokeLater(() -> { + Map> existingIssues = ProblemHolderService.getInstance(project).getAllIssues(); + if (existingIssues != null && !existingIssues.isEmpty()) { + triggerRefreshTree(); + } + }); + + // Subscribe to scan issue updates to refresh tree automatically + project.getMessageBus().connect(this) + .subscribe(ProblemHolderService.ISSUE_TOPIC, new ProblemHolderService.IssueListener() { + @Override + public void onIssuesUpdated(Map> issues) { + ApplicationManager.getApplication().invokeLater(() -> triggerRefreshTree()); + } + }); + } + + /** + * Draw the authentication panel prompting the user to configure settings. + * + */ + private void drawAuthPanel() { + removeAll(); + JPanel wrapper = new JPanel(new GridBagLayout()); + + JPanel panel = new JPanel(new GridLayoutManager(2, 1, JBUI.emptyInsets(), -1, -1)); + + GridConstraints constraints = new GridConstraints(); + constraints.setRow(0); + panel.add(new JBLabel(CxIcons.CHECKMARX_80), constraints); + + JButton openSettingsButton = new JButton(Bundle.message(Resource.OPEN_SETTINGS_BUTTON)); + openSettingsButton.addActionListener(e -> ShowSettingsUtil.getInstance() + .showSettingsDialog(project, GlobalSettingsConfigurable.class)); + + constraints = new GridConstraints(); + constraints.setRow(1); + panel.add(openSettingsButton, constraints); + + wrapper.add(panel); + + setContent(wrapper); + + } + + /** + * Draw the main panel with toolbar and tree inside a scroll pane. + * Shown when global settings are valid. + */ + private void drawMainPanel() { + removeAll(); + + // Create and set toolbar + ActionToolbar toolbar = createActionToolbar(); + toolbar.setTargetComponent(this); + setToolbar(toolbar.getComponent()); + + // Add tree inside scroll pane + JBScrollPane scrollPane = new JBScrollPane(tree); + setContent(scrollPane); + + revalidate(); + repaint(); + } + + /** + * Retrieve issues, apply filtering, and refresh the UI tree. + */ + private void triggerRefreshTree() { + Map> allIssues = ProblemHolderService.getInstance(project).getAllIssues(); + if (allIssues == null) { + return; + } + + Set activeFilters = VulnerabilityFilterState.getInstance().getFilters(); + + Map> filteredIssues = new HashMap<>(); + + for (Map.Entry> entry : allIssues.entrySet()) { + List filteredList = entry.getValue().stream() + .filter(issue -> activeFilters.stream() + .anyMatch(f -> f.getFilterValue().equalsIgnoreCase(issue.getSeverity()))) + .collect(Collectors.toList()); + + if (!filteredList.isEmpty()) { + filteredIssues.put(entry.getKey(), filteredList); + } + } + refreshTree(filteredIssues); + } + + public void refreshTree(Map> issues) { + int rowCount = tree.getRowCount(); + for (int i = 0; i < rowCount; i++) { + TreePath path = tree.getPathForRow(i); + if (path != null && tree.isExpanded(path)) { + Object lastNode = path.getLastPathComponent(); + if (lastNode instanceof DefaultMutableTreeNode) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode) lastNode; + Object userObject = node.getUserObject(); + if (userObject instanceof FileNodeLabel) { + expandedPathsSet.add(((FileNodeLabel) userObject).filePath); + } + } + } + } + // Clear and rebuild the tree + rootNode.removeAllChildren(); + for (Map.Entry> entry : issues.entrySet()) { + String filePath = entry.getKey(); + String fileName = getSecureFileName(filePath); + List scanDetails = entry.getValue(); + + // Filtered problems (excluding "ok" and "unknown") + List filteredScanDetails = scanDetails.stream() + .filter(detail -> { + String severity = detail.getSeverity(); + return !Constants.OK.equalsIgnoreCase(severity) && !Constants.UNKNOWN.equalsIgnoreCase(severity); + }) + .collect(Collectors.toList()); + + VirtualFile virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath); + Icon icon = virtualFile != null ? virtualFile.getFileType().getIcon() : null; + PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile); + if (psiFile != null) { + icon = psiFile.getIcon(Iconable.ICON_FLAG_VISIBILITY | Iconable.ICON_FLAG_READ_STATUS); + } + + // Count issues by severity and sort them based on severity order + Map severityCounts = filteredScanDetails.stream() + .collect(Collectors.groupingBy(ScanIssue::getSeverity, Collectors.counting())); + severityCounts = List.of(Constants.MALICIOUS_SEVERITY, Constants.CRITICAL_SEVERITY, Constants.HIGH_SEVERITY, + Constants.MEDIUM_SEVERITY, Constants.LOW_SEVERITY).stream() + .filter(severityCounts::containsKey) + .collect(Collectors.toMap( + s -> s, + severityCounts::get, + (a, b) -> a, + LinkedHashMap::new + )); + DefaultMutableTreeNode fileNode = new DefaultMutableTreeNode( + new FileNodeLabel(fileName, filePath, severityCounts, icon)); + + for (ScanIssue detail : filteredScanDetails) { + fileNode.add(new DefaultMutableTreeNode(new ScanDetailWithPath(detail, filePath))); + } + + rootNode.add(fileNode); + } + ((DefaultTreeModel) tree.getModel()).reload(); + + expandNodesByFilePath(); + } + + /** + * Expand nodes by file path after reload. + */ + private void expandNodesByFilePath() { + SwingUtilities.invokeLater(() -> { + for (int i = 0; i < tree.getRowCount(); i++) { + TreePath path = tree.getPathForRow(i); + if (path != null) { + Object lastNode = path.getLastPathComponent(); + if (lastNode instanceof DefaultMutableTreeNode) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode) lastNode; + Object userObject = node.getUserObject(); + if (userObject instanceof FileNodeLabel && + expandedPathsSet.contains(((FileNodeLabel) userObject).filePath)) { + tree.expandPath(path); + } + } + } + } + }); + } + + private void navigateToSelectedIssue() { + Object selected = tree.getLastSelectedPathComponent(); + if (!(selected instanceof DefaultMutableTreeNode)) + return; + DefaultMutableTreeNode node = (DefaultMutableTreeNode) selected; + Object userObj = node.getUserObject(); + if (!(userObj instanceof ScanDetailWithPath)) + return; + + ScanDetailWithPath detailWithPath = (ScanDetailWithPath) userObj; + ScanIssue detail = detailWithPath.detail; + String filePath = detailWithPath.filePath; + + if (detail.getLocations() != null && !detail.getLocations().isEmpty()) { + Location targetLoc = detail.getLocations().get(0); + + int lineNumber = targetLoc.getLine(); + + VirtualFile virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath); + if (virtualFile == null) + return; + + FileEditorManager editorManager = FileEditorManager.getInstance(project); + editorManager.openFile(virtualFile, true); + Editor editor = editorManager.getSelectedTextEditor(); + if (editor != null) { + Document document = editor.getDocument(); + LogicalPosition logicalPosition = new LogicalPosition(lineNumber - 1, 0); + editor.getCaretModel().moveToLogicalPosition(logicalPosition); + editor.getScrollingModel().scrollTo(logicalPosition, ScrollType.CENTER); + + } + } + } + + private void handleRightClick(MouseEvent e) { + if (!e.isPopupTrigger()) + return; + int row = tree.getClosestRowForLocation(e.getX(), e.getY()); + tree.setSelectionRow(row); + Object selected = tree.getLastSelectedPathComponent(); + if (!(selected instanceof DefaultMutableTreeNode)) + return; + DefaultMutableTreeNode node = (DefaultMutableTreeNode) selected; + Object userObj = node.getUserObject(); + if (!(userObj instanceof ScanDetailWithPath)) + return; + + ScanIssue detail = ((ScanDetailWithPath) userObj).detail; + JPopupMenu popup = createPopupMenu(detail); + popup.show(tree, e.getX(), e.getY()); + } + + @Override + public void dispose() { + // Cleanup if needed + } + + public static class ScanDetailWithPath { + public final ScanIssue detail; + public final String filePath; + + public ScanDetailWithPath(ScanIssue detail, String filePath) { + this.detail = detail; + this.filePath = filePath; + } + } + + private JPopupMenu createPopupMenu(ScanIssue detail) { + JPopupMenu popup = new JPopupMenu(); + + JMenuItem fixWithCxOneAssist = new JMenuItem(Constants.RealTimeConstants.FIX_WITH_CXONE_ASSIST); + fixWithCxOneAssist.addActionListener(ev -> remediationManager.fixWithCxOneAssist(project, detail)); + fixWithCxOneAssist.setIcon(CxIcons.STAR_ACTION); + popup.add(fixWithCxOneAssist); + + JMenuItem copyDescription = new JMenuItem(Constants.RealTimeConstants.VIEW_DETAILS_FIX_NAME); + copyDescription.addActionListener(ev -> remediationManager.viewDetails(project, detail)); + copyDescription.setIcon(CxIcons.STAR_ACTION); + popup.add(copyDescription); + + JMenuItem ignoreOption = new JMenuItem(Constants.RealTimeConstants.IGNORE_THIS_VULNERABILITY_FIX_NAME); + ignoreOption.setIcon(CxIcons.STAR_ACTION); + popup.add(ignoreOption); + + JMenuItem ignoreAllOption = new JMenuItem(Constants.RealTimeConstants.IGNORE_ALL_OF_THIS_TYPE_FIX_NAME); + ignoreAllOption.setIcon(CxIcons.STAR_ACTION); + popup.add(ignoreAllOption); + popup.add(new JSeparator()); + + JMenuItem copyFix = new JMenuItem("Copy"); + copyFix.addActionListener(ev -> { + try { + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(Collections.singletonList(detail)); + Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new StringSelection(json), null); + } catch (Exception e) { + LOGGER.warn("Failed to copy fix details", e); + } + }); + popup.add(copyFix); + + JMenuItem copyMessage = new JMenuItem("Copy Message"); + copyMessage.addActionListener(ev -> { + String message = detail.getSeverity() + "-risk package: " + detail.getTitle() + "@" + + detail.getPackageVersion(); + Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new StringSelection(message), null); + }); + popup.add(copyMessage); + return popup; + } + + private String getSecureFileName(String filePath) { + if (filePath == null || filePath.trim().isEmpty()) { + return Constants.UNKNOWN; + } + try { + Path path = Paths.get(filePath).normalize(); + Path fileName = path.getFileName(); + if (fileName != null) { + return fileName.toString(); + } + return path.toString(); + } catch (java.nio.file.InvalidPathException e) { + return filePath.substring(Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + 1); + } + } + + public static class FileNodeLabel { + public final String fileName; + public final String filePath; + public final Map problemCount; + public final Icon icon; + + public FileNodeLabel(String fileName, String filePath, Map problemCount, Icon icon) { + this.fileName = fileName; + this.filePath = filePath; + this.problemCount = problemCount; + this.icon = icon; + } + } + + public void updateTabTitle() { + int count = getProblemCount(); + if (count > 0) { + JBColor jbColor = new JBColor(Gray._10, Gray._190); + String hexColor = "#" + Integer.toHexString(jbColor.getRGB()).substring(2); + content.setDisplayName("" + Constants.RealTimeConstants.DEVASSIST_TAB + " " + count + ""); + }else { + content.setDisplayName(Constants.RealTimeConstants.DEVASSIST_TAB); + } + } + + public int getProblemCount() { + int count = 0; + Enumeration children = rootNode.children(); + while (children.hasMoreElements()) { + DefaultMutableTreeNode fileNode = (DefaultMutableTreeNode) children.nextElement(); + count += fileNode.getChildCount(); // problems under each file node + } + return count; + } + + @NotNull + private ActionToolbar createActionToolbar() { + ActionGroup originalXmlGroup = + (ActionGroup) ActionManager.getInstance().getAction("VulnerabilityToolbarGroup"); + + DefaultActionGroup newGroup = new DefaultActionGroup(); + DataContext dataContext = DataManager.getInstance().getDataContext(/* component or null */); + AnActionEvent event = AnActionEvent.createFromDataContext( + ActionPlaces.TOOLBAR, + null, + dataContext + ); + for (AnAction a : originalXmlGroup.getChildren(event)) { + newGroup.add(a); + } + + // Add Expand/Collapse actions + AnAction expandAll = ActionManager.getInstance().getAction("Checkmarx.ExpandAll"); + AnAction collapseAll = ActionManager.getInstance().getAction("Checkmarx.CollapseAll"); + + newGroup.add(expandAll); + newGroup.add(collapseAll); + + ActionToolbar toolbar = ActionManager.getInstance() + .createActionToolbar(Constants.TOOL_WINDOW_ID, newGroup, false); + + toolbar.setTargetComponent(this); + return toolbar; + } + + private void initVulnerabilityIcons() { + vulnerabilityToIcon = new HashMap<>(); + vulnerabilityToIcon.put(Constants.MALICIOUS_SEVERITY, CxIcons.Small.MALICIOUS); + vulnerabilityToIcon.put(Constants.CRITICAL_SEVERITY, CxIcons.Small.CRITICAL); + vulnerabilityToIcon.put(Constants.HIGH_SEVERITY, CxIcons.Small.HIGH); + vulnerabilityToIcon.put(Constants.MEDIUM_SEVERITY, CxIcons.Small.MEDIUM); + vulnerabilityToIcon.put(Constants.LOW_SEVERITY, CxIcons.Small.LOW); + } + + private void initVulnerabilityCountIcons() { + vulnerabilityCountToIcon = new HashMap<>(); + vulnerabilityCountToIcon.put(Constants.MALICIOUS_SEVERITY, CxIcons.Medium.MALICIOUS); + vulnerabilityCountToIcon.put(Constants.CRITICAL_SEVERITY, CxIcons.Medium.CRITICAL); + vulnerabilityCountToIcon.put(Constants.HIGH_SEVERITY, CxIcons.Medium.HIGH); + vulnerabilityCountToIcon.put(Constants.MEDIUM_SEVERITY, CxIcons.Medium.MEDIUM); + vulnerabilityCountToIcon.put(Constants.LOW_SEVERITY, CxIcons.Medium.LOW); + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/devassist/ui/findings/window/IssueTreeRenderer.java b/src/main/java/com/checkmarx/intellij/devassist/ui/findings/window/IssueTreeRenderer.java new file mode 100644 index 00000000..1dcbeca5 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/ui/findings/window/IssueTreeRenderer.java @@ -0,0 +1,223 @@ +package com.checkmarx.intellij.devassist.ui.findings.window; + +import com.checkmarx.intellij.*; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.ui.ColoredTreeCellRenderer; +import com.intellij.ui.Gray; +import com.intellij.ui.JBColor; +import com.intellij.ui.SimpleTextAttributes; +import javax.swing.*; +import javax.swing.tree.DefaultMutableTreeNode; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Renders the vulnerability tree. + * Extends {@link ColoredTreeCellRenderer} to provide custom rendering logic. + * Handles mouse hover events to show an intention bulb icon. + * Displays severity icons with counts next to file names. + * Uses vulnerability severity to determine which icons to display. + * + */ +public class IssueTreeRenderer extends ColoredTreeCellRenderer { + + private static final Logger LOGGER = Utils.getLogger(IssueTreeRenderer.class); + + private int hoveredRow = -1; + private int currentRow = -1; + private final List severityIconsToDraw = new ArrayList<>(); + private String fileNameText = ""; + + private final Map vulnerabilityToIcon; + private final Map vulnerabilityCountToIcon; + + public IssueTreeRenderer(JTree tree, Map vulnerabilityToIcon, Map vulnerabilityCountToIcon) { + this.vulnerabilityToIcon = vulnerabilityToIcon; + this.vulnerabilityCountToIcon = vulnerabilityCountToIcon; + + tree.addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + int row = tree.getRowForLocation(e.getX(), e.getY()); + if (row != hoveredRow) { + hoveredRow = row; + tree.repaint(); + } + } + }); + + tree.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + int row = tree.getRowForLocation(e.getX(), e.getY()); + if (row == -1) { + tree.clearSelection(); + } + } + }); + } + + + /** + * Customizes the cell renderer for the vulnerability tree. + * @param tree the tree instance + * @param value the node value + * @param selected whether the node is selected + * @param expanded whether the node is expanded + * @param leaf whether the node is a leaf + * @param row the row index + * @param hasFocus whether the tree has focus + */ + @Override + public void customizeCellRenderer(JTree tree, Object value, boolean selected, + boolean expanded, boolean leaf, int row, boolean hasFocus) { + currentRow = row; + severityIconsToDraw.clear(); + fileNameText = ""; + + if (!(value instanceof DefaultMutableTreeNode)) + return; + + DefaultMutableTreeNode node = (DefaultMutableTreeNode) value; + Object obj = node.getUserObject(); + Icon icon = null; + LOGGER.debug("Rendering the result tree"); + if (obj instanceof CxFindingsWindow.FileNodeLabel) { + CxFindingsWindow.FileNodeLabel info = (CxFindingsWindow.FileNodeLabel) obj; + if (info.icon != null) { + setIcon(info.icon); + } + fileNameText = info.fileName; + append(info.fileName, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES); + if (info.problemCount != null && !info.problemCount.isEmpty()) { + append(" ", SimpleTextAttributes.GRAY_ATTRIBUTES); + for (Map.Entry entry : info.problemCount.entrySet()) { + Long count = entry.getValue(); + if (count != null && count > 0) { + Icon severityIcon = vulnerabilityCountToIcon.get(entry.getKey()); + if (severityIcon != null) { + severityIconsToDraw.add(new IconWithCount(severityIcon, count)); + } + } + } + } + } else if (obj instanceof CxFindingsWindow.ScanDetailWithPath) { + ScanIssue detail = ((CxFindingsWindow.ScanDetailWithPath) obj).detail; + + icon = vulnerabilityToIcon.getOrDefault(detail.getSeverity(), null); + if (icon != null) + setIcon(icon); + + switch (detail.getScanEngine()) { + case ASCA: + append(detail.getTitle() + " ", SimpleTextAttributes.REGULAR_ATTRIBUTES); + break; + case OSS: + append(detail.getSeverity() + "-risk package: " + detail.getTitle() + "@" + + detail.getPackageVersion(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + break; + default: + append(detail.getDescription(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + break; + } + append(" " + Constants.CXONE_ASSIST + " ", SimpleTextAttributes.GRAYED_ATTRIBUTES); + + if (detail.getLocations() != null && !detail.getLocations().isEmpty()) { + var targetLoc = detail.getLocations().get(0); + int line = targetLoc.getLine(); + Integer column = Math.max(0, targetLoc.getStartIndex()); + String lineColText = "[Ln " + line; + if (column != null) { + lineColText += ", Col " + column; + } + lineColText += "]"; + append(lineColText, SimpleTextAttributes.GRAYED_ATTRIBUTES); + } + } else if (obj instanceof String) { + setIcon(null); + append((String) obj, SimpleTextAttributes.REGULAR_ATTRIBUTES); + } + } + + /** + * Paints the component with custom graphics. + * @param g the Graphics object to protect + */ + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + if (hoveredRow == currentRow) { + Graphics2D g2d = (Graphics2D) g.create(); + try { + g2d.setColor(new Color(211, 211, 211, 40)); + g2d.fillRect(0, 0, getWidth(), getHeight()); + } finally { + g2d.dispose(); + } + } + if (!severityIconsToDraw.isEmpty() && !fileNameText.isEmpty()) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + FontMetrics fm = getFontMetrics(getFont()); + + int x = getIpad().left; + if (getIcon() != null) { + x += getIcon().getIconWidth() + getIconTextGap(); + } + x += fm.stringWidth("__"); + x += fm.stringWidth(fileNameText); + x += fm.stringWidth(" "); + + int y = (getHeight() - 16) / 2; + + int iconCountSpacing = 5; + int iconNumberSpacing = 1; + + for (IconWithCount iconWithCount : severityIconsToDraw) { + iconWithCount.icon.paintIcon(this, g2, x, y); + + x += iconWithCount.icon.getIconWidth() + iconNumberSpacing; + + String countStr = iconWithCount.count.toString(); + int countWidth = fm.stringWidth(countStr); + int countY = y + (iconWithCount.icon.getIconHeight() + fm.getAscent()) / 2 - 2; + + g2.setColor(new JBColor(Gray._10, Gray._190)); + g2.setFont(getFont().deriveFont(Font.BOLD)); + g2.drawString(countStr, x, countY); + + x += countWidth + iconCountSpacing; + } + + } finally { + g2.dispose(); + } + } + } + + + /** + * Helper class to hold an icon and its associated count. + * Used for rendering severity icons with counts. + */ + + private static class IconWithCount { + final Icon icon; + final Long count; + + IconWithCount(Icon icon, Long count) { + this.icon = icon; + this.count = count; + } + } +} + diff --git a/src/main/java/com/checkmarx/intellij/devassist/utils/DevAssistUtils.java b/src/main/java/com/checkmarx/intellij/devassist/utils/DevAssistUtils.java new file mode 100644 index 00000000..cf75d43d --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/utils/DevAssistUtils.java @@ -0,0 +1,269 @@ +package com.checkmarx.intellij.devassist.utils; + +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.devassist.configuration.GlobalScannerController; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.checkmarx.intellij.util.SeverityLevel; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.util.ui.UIUtil; +import org.jetbrains.annotations.NotNull; + +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.io.IOException; +import java.net.URL; + +import static java.lang.String.format; + +/** + * Utility class for common operations. + */ +public class DevAssistUtils { + private static final Logger LOGGER = Utils.getLogger(DevAssistUtils.class); + + + private DevAssistUtils() { + // Private constructor to prevent instantiation + } + + public static GlobalScannerController globalScannerController() { + return GlobalScannerController.getInstance(); + } + + /** + * Checks if the scanner with the given name is active. + * + * @param engineName the name of the scanner to check + * @return true if the scanner is active, false otherwise + */ + public static boolean isScannerActive(String engineName) { + if (engineName == null) return false; + try { + if (GlobalSettingsState.getInstance().isAuthenticated()) { + ScanEngine kind = ScanEngine.valueOf(engineName.toUpperCase()); + return globalScannerController().isScannerGloballyEnabled(kind); + } + } catch (IllegalArgumentException ex) { + return false; + } + return false; + } + + /** + * Checks if any scanner is enabled. + * + * @return true if any scanner is enabled, false otherwise + */ + public static boolean isAnyScannerEnabled() { + return globalScannerController().checkAnyScannerEnabled(); + } + + + /** + * Retrieves the text range for the specified line in the given document, trimming leading and trailing whitespace. + * + * @param document the document from which the specified line's text range is to be retrieved + * @param problemLineNumber the 1-based line number for which the text range is needed + * @return a TextRange representing the trimmed start and end offsets of the specified line + */ + public static TextRange getTextRangeForLine(Document document, int problemLineNumber) { + // Convert to 0-based index for document API + int lineIndex = problemLineNumber - 1; + + // Get the exact offsets for this line + int lineStartOffset = document.getLineStartOffset(lineIndex); + int lineEndOffset = document.getLineEndOffset(lineIndex); + + // Get the line text and trim whitespace for highlighting + CharSequence chars = document.getCharsSequence(); + + // Calculate leading spaces + int trimmedStartOffset = lineStartOffset; + while (trimmedStartOffset < lineEndOffset && Character.isWhitespace(chars.charAt(trimmedStartOffset))) { + trimmedStartOffset++; + } + // Calculate trailing spaces + int trimmedEndOffset = lineEndOffset; + while (trimmedEndOffset > trimmedStartOffset && Character.isWhitespace(chars.charAt(trimmedEndOffset - 1))) { + trimmedEndOffset--; + } + // Ensure a valid range (fallback to original if the line is all whitespace) + if (trimmedStartOffset >= trimmedEndOffset) { + return new TextRange(lineStartOffset, lineEndOffset); + } + return new TextRange(trimmedStartOffset, trimmedEndOffset); + } + + /** + * Checks if the given line number is out of range for the document. + * + * @param lineNumber the line number to check (1-based) + * @param document the document + * @return true if the line number is out of range, false otherwise + */ + public static boolean isLineOutOfRange(int lineNumber, Document document) { + return lineNumber <= 0 || lineNumber > document.getLineCount(); + } + + /** + * Wraps the given text into lines at word boundaries without exceeding a defined maximum line length. + * If a word exceeds the specified line length, it will be placed on a new line. + * + * @param text the input text to be wrapped into lines + * @return the text with line breaks added to wrap it at word boundaries + */ + public static String wrapTextAtWord(String text, int maxLineLength) { + StringBuilder result = new StringBuilder(); + int lineLength = 0; + for (String word : text.split(" ")) { + if (lineLength > 0) { + // Add a space before the word if not at the start of a line + result.append(" "); + lineLength++; + } + if (lineLength + word.length() > maxLineLength) { + // Start a new line before adding the word + result.append("\n"); + result.append(word); + lineLength = word.length(); + } else { + result.append(word); + lineLength += word.length(); + } + } + return result.toString(); + } + + /** + * Checks if the scan package is a problem. + * + * @param severity - the severity of the scan package e.g. "high", "medium", "low", etc. + * @return true if the scan package is a problem, false otherwise + */ + public static boolean isProblem(String severity) { + if (severity.equalsIgnoreCase(SeverityLevel.OK.getSeverity())) { + return false; + } else return !severity.equalsIgnoreCase(SeverityLevel.UNKNOWN.getSeverity()); + } + + /** + * Returns a resource URL string suitable for embedding in an tag + * for the given simple icon key (e.g. "critical", "high", "package", "malicious"). + * + * @param iconPath severity or logical icon path + * @return external form URL or empty string if not found + */ + public static String themeBasedPNGIconForHtmlImage(String iconPath) { + if (iconPath == null || iconPath.isEmpty()) { + return ""; + } + boolean dark = isDarkTheme(); + // Try the dark variant first if in a dark theme. + String candidate = iconPath + (dark ? "_dark" : "") + ".png"; + URL res = DevAssistUtils.class.getResource(candidate); + if (res == null && dark) { + // Fallback to the light variant. + candidate = iconPath + ".png"; + res = DevAssistUtils.class.getResource(candidate); + } + return res != null ? res.toExternalForm() : ""; + } + + /** + * Checks if the IDE is in a dark theme. + * + * @return true if in a dark theme, false otherwise + */ + public static boolean isDarkTheme() { + return UIUtil.isUnderDarcula(); + } + + /** + * Returns the full textual content of the given {@link PsiFile}, including both + * unsaved in-editor changes and updates made to the underlying file outside the IDE. + *

+ * This method first attempts to read from the associated {@link Document}, ensuring + * that any unsaved modifications in the editor are included. If no document is + * associated with the PSI file, the content is loaded directly from the + * {@link VirtualFile}, ensuring externally modified file content is retrieved. + *

+ * All operations are performed inside a read action as required by the IntelliJ Platform. + * + * @param file the PSI file whose content should be read + * @return the full file text, or {@code null} if the file cannot be accessed + */ + + public static String getFileContent(@NotNull PsiFile file) { + return ApplicationManager.getApplication().runReadAction((Computable) () -> { + + Document document = PsiDocumentManager.getInstance(file.getProject()).getDocument(file); + if (document != null) { + return document.getText(); + } + VirtualFile virtualFile = file.getVirtualFile(); + if (virtualFile == null) { + LOGGER.warn("Virtual file is null for PsiFile: " + file.getName()); + return null; + } + try { + return VfsUtil.loadText(virtualFile); + } catch (IOException e) { + LOGGER.warn("Failed to load content from file: " + virtualFile.getPath(), e); + return null; + } + }); + } + + + /** + * Copies the given text to the system clipboard and shows a notification on success. + * + * @param textToCopy the text to copy + * @param project the project in which the notification should be shown + * @param notificationTitle the title of the notification + * @param notificationContent the content of the notification + */ + public static boolean copyToClipboardWithNotification(@NotNull String textToCopy, String notificationTitle, + String notificationContent, Project project) { + StringSelection stringSelection = new StringSelection(textToCopy); + try { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(stringSelection, null); + Utils.showNotification(notificationTitle, notificationContent, + NotificationType.INFORMATION, + project); + return true; + } catch (Exception exception) { + LOGGER.debug("Failed to copy text to clipboard: ", exception); + Utils.showNotification(notificationTitle, "Failed to copy text to clipboard.", NotificationType.ERROR, null); + return false; + } + } + + /** + * Gets the PsiElement at the start of the specified line number in the given PsiFile and Document. + * + * @param file PsiFile + * @param document Document + * @param lineNumber line number + * @return PsiElement + */ + public static PsiElement getPsiElement(PsiFile file, Document document, int lineNumber) { + try { + return file.findElementAt(document.getLineStartOffset(lineNumber - 1)); // Convert to 0-based index + } catch (Exception e) { + LOGGER.error(format("Exception occurred while getting PsiElement for line number: %s", lineNumber), e); + return null; + } + } +} diff --git a/src/main/java/com/checkmarx/intellij/devassist/utils/ScanEngine.java b/src/main/java/com/checkmarx/intellij/devassist/utils/ScanEngine.java new file mode 100644 index 00000000..e3425e4f --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/devassist/utils/ScanEngine.java @@ -0,0 +1,21 @@ +package com.checkmarx.intellij.devassist.utils; + +/** + * Enumeration representing various scanning engines supported by the system. + * Each constant signifies a specific type of scanning capability provided by the platform. + * + * The available scanning engines are: + * - OSS: Represents scanning for Open Source Software dependencies and vulnerabilities. + * - SECRETS: Represents scanning for sensitive information such as secrets and credentials in the code. + * - CONTAINERS: Represents scanning for vulnerabilities in container images. + * - IAC: Represents scanning for Infrastructure as Code issues and misconfigurations. + * - ASCA: Represents scanning for Application Security Code Analysis. + */ +public enum ScanEngine { + + OSS, + SECRETS, + CONTAINERS, + IAC, + ASCA, +} diff --git a/src/main/java/com/checkmarx/intellij/inspections/AscaInspection.java b/src/main/java/com/checkmarx/intellij/inspections/AscaInspection.java index ec433a49..496ba021 100644 --- a/src/main/java/com/checkmarx/intellij/inspections/AscaInspection.java +++ b/src/main/java/com/checkmarx/intellij/inspections/AscaInspection.java @@ -2,16 +2,21 @@ import com.checkmarx.ast.asca.ScanDetail; import com.checkmarx.ast.asca.ScanResult; -import com.checkmarx.intellij.service.AscaService; import com.checkmarx.intellij.Constants; import com.checkmarx.intellij.Utils; import com.checkmarx.intellij.inspections.quickfixes.AscaQuickFix; +import com.checkmarx.intellij.service.AscaService; import com.checkmarx.intellij.settings.global.GlobalSettingsState; -import com.intellij.codeInspection.*; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.ProblemHighlightType; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.Document; import com.intellij.openapi.util.TextRange; -import com.intellij.psi.*; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; import lombok.Getter; import lombok.Setter; import org.jetbrains.annotations.NotNull; @@ -36,8 +41,8 @@ public class AscaInspection extends LocalInspectionTool { /** * Checks the file for ASCA issues. * - * @param file the file to check - * @param manager the inspection manager + * @param file the file to check + * @param manager the inspection manager * @param isOnTheFly whether the inspection is on-the-fly * @return an array of problem descriptors */ @@ -59,8 +64,7 @@ public class AscaInspection extends LocalInspectionTool { } return createProblemDescriptors(file, manager, scanResult.getScanDetails(), document, isOnTheFly); - } - catch (Exception e) { + } catch (Exception e) { logger.warn("Failed to run ASCA scan", e); return ProblemDescriptor.EMPTY_ARRAY; } @@ -69,11 +73,11 @@ public class AscaInspection extends LocalInspectionTool { /** * Creates problem descriptors for the given scan details. * - * @param file the file to check - * @param manager the inspection manager + * @param file the file to check + * @param manager the inspection manager * @param scanDetails the scan details - * @param document the document - * @param isOnTheFly whether the inspection is on-the-fly + * @param document the document + * @param isOnTheFly whether the inspection is on-the-fly * @return an array of problem descriptors */ private ProblemDescriptor[] createProblemDescriptors(@NotNull PsiFile file, @NotNull InspectionManager manager, List scanDetails, Document document, boolean isOnTheFly) { @@ -98,10 +102,10 @@ private ProblemDescriptor[] createProblemDescriptors(@NotNull PsiFile file, @Not /** * Creates a problem descriptor for a specific scan detail. * - * @param file the file to check - * @param manager the inspection manager - * @param detail the scan detail - * @param document the document + * @param file the file to check + * @param manager the inspection manager + * @param detail the scan detail + * @param document the document * @param lineNumber the line number * @param isOnTheFly whether the inspection is on-the-fly * @return a problem descriptor @@ -137,7 +141,7 @@ private String escapeHtml(String text) { /** * Gets the text range for a specific line in the document. * - * @param document the document + * @param document the document * @param lineNumber the line number * @return the text range */ @@ -155,7 +159,7 @@ private TextRange getTextRangeForLine(Document document, int lineNumber) { * Checks if the line number is out of range in the document. * * @param lineNumber the line number - * @param document the document + * @param document the document * @return true if the line number is out of range, false otherwise */ private boolean isLineOutOfRange(int lineNumber, Document document) { @@ -190,10 +194,10 @@ private ProblemHighlightType determineHighlightType(ScanDetail detail) { private Map getSeverityToHighlightMap() { if (severityToHighlightMap == null) { severityToHighlightMap = new HashMap<>(); - severityToHighlightMap.put(Constants.ASCA_CRITICAL_SEVERITY, ProblemHighlightType.GENERIC_ERROR); - severityToHighlightMap.put(Constants.ASCA_HIGH_SEVERITY, ProblemHighlightType.GENERIC_ERROR); - severityToHighlightMap.put(Constants.ASCA_MEDIUM_SEVERITY, ProblemHighlightType.WARNING); - severityToHighlightMap.put(Constants.ASCA_LOW_SEVERITY, ProblemHighlightType.WEAK_WARNING); + severityToHighlightMap.put(Constants.CRITICAL_SEVERITY, ProblemHighlightType.GENERIC_ERROR); + severityToHighlightMap.put(Constants.HIGH_SEVERITY, ProblemHighlightType.GENERIC_ERROR); + severityToHighlightMap.put(Constants.MEDIUM_SEVERITY, ProblemHighlightType.WARNING); + severityToHighlightMap.put(Constants.LOW_SEVERITY, ProblemHighlightType.WEAK_WARNING); } return severityToHighlightMap; } diff --git a/src/main/java/com/checkmarx/intellij/inspections/CxVisitor.java b/src/main/java/com/checkmarx/intellij/inspections/CxVisitor.java index 935f3a79..c9c288b5 100644 --- a/src/main/java/com/checkmarx/intellij/inspections/CxVisitor.java +++ b/src/main/java/com/checkmarx/intellij/inspections/CxVisitor.java @@ -96,8 +96,11 @@ private boolean alreadyRegistered(Node node) { * @return start offset in the file */ private static int getStartOffset(Node node) { - // if definitions is -1, the column field points to the end of the token so we have to subtract the length - return node.getColumn() - 1 + (node.getDefinitions().equals("-1") ? (node.getLength() * -1) : 0); + String definitions = node.getDefinitions(); + if ("-1".equals(definitions)) { + return node.getColumn() - 1 - node.getLength(); + } + return node.getColumn() - 1; } /** @@ -107,8 +110,11 @@ private static int getStartOffset(Node node) { * @return end offset in the file */ private static int getEndOffset(Node node) { - // if definitions is not -1, the column field points to the start of the token so we have to add the length - return node.getColumn() - 1 + (node.getDefinitions().equals("-1") ? 0 : node.getLength()); + String definitions = node.getDefinitions(); + if ("-1".equals(definitions)) { + return node.getColumn() - 1; + } + return node.getColumn() - 1 + node.getLength(); } /** diff --git a/src/main/java/com/checkmarx/intellij/project/ProjectListener.java b/src/main/java/com/checkmarx/intellij/project/ProjectListener.java index d844dd38..e64fed4b 100644 --- a/src/main/java/com/checkmarx/intellij/project/ProjectListener.java +++ b/src/main/java/com/checkmarx/intellij/project/ProjectListener.java @@ -1,15 +1,31 @@ package com.checkmarx.intellij.project; + import com.checkmarx.intellij.commands.results.Results; + +import com.checkmarx.intellij.devassist.configuration.ScannerLifeCycleManager; +import com.checkmarx.intellij.devassist.registry.ScannerRegistry; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.intellij.openapi.progress.EmptyProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManagerListener; import org.jetbrains.annotations.NotNull; public class ProjectListener implements ProjectManagerListener { + private ScannerLifeCycleManager scannerManager; + @Override public void projectOpened(@NotNull Project project) { ProjectManagerListener.super.projectOpened(project); project.getService(ProjectResultsService.class).indexResults(project, Results.emptyResults); + if (GlobalSettingsState.getInstance().isAuthenticated()) { + ProgressManager.getInstance().runProcess(() -> { + ScannerRegistry scannerRegistry = project.getService(ScannerRegistry.class); + scannerRegistry.registerAllScanners(project); + }, new EmptyProgressIndicator()); + + } } } diff --git a/src/main/java/com/checkmarx/intellij/settings/global/CxOneAssistComponent.java b/src/main/java/com/checkmarx/intellij/settings/global/CxOneAssistComponent.java new file mode 100644 index 00000000..337fec4e --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/settings/global/CxOneAssistComponent.java @@ -0,0 +1,503 @@ +package com.checkmarx.intellij.settings.global; + +import com.checkmarx.intellij.Bundle; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.Utils; +import com.checkmarx.intellij.components.CxLinkLabel; +import com.checkmarx.intellij.settings.SettingsComponent; +import com.checkmarx.intellij.settings.SettingsListener; +import com.checkmarx.intellij.devassist.configuration.mcp.McpInstallService; +import com.checkmarx.intellij.devassist.configuration.mcp.McpSettingsInjector; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.util.messages.MessageBusConnection; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.JBColor; +import net.miginfocom.swing.MigLayout; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.concurrent.CompletableFuture; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.fileEditor.FileEditorManager; + +/** + * UI component shown under Tools > Checkmarx One > CxOne Assist. + * Currently shows OSS realtime scanner toggle and MCP configuration installation. + * Other realtime scanners (secrets, containers, IaC) and container management tools are temporarily hidden and will be restored in a future release. + * MCP status is shown inline in the UI. + */ +public class CxOneAssistComponent implements SettingsComponent, Disposable { + + private static final Logger LOGGER = Utils.getLogger(CxOneAssistComponent.class); + + private final JPanel mainPanel = new JPanel(new MigLayout("", "[][grow]")); + + private final JBLabel ossTitle = new JBLabel(formatTitle(Bundle.message(Resource.OSS_REALTIME_TITLE))); + private final JBCheckBox ossCheckbox = new JBCheckBox(Bundle.message(Resource.OSS_REALTIME_CHECKBOX)); + private final JBLabel assistMessageLabel = new JBLabel(); + + // TEMPORARILY HIDDEN FIELDS - Will be restored in future release + @SuppressWarnings("unused") + private final JBLabel secretsTitle = new JBLabel(formatTitle(Bundle.message(Resource.SECRETS_REALTIME_TITLE))); + @SuppressWarnings("unused") + private final JBCheckBox secretsCheckbox = new JBCheckBox(Bundle.message(Resource.SECRETS_REALTIME_CHECKBOX)); + + @SuppressWarnings("unused") + private final JBLabel containersTitle = new JBLabel(formatTitle(Bundle.message(Resource.CONTAINERS_REALTIME_TITLE))); + @SuppressWarnings("unused") + private final JBCheckBox containersCheckbox = new JBCheckBox(Bundle.message(Resource.CONTAINERS_REALTIME_CHECKBOX)); + + @SuppressWarnings("unused") + private final JBLabel iacTitle = new JBLabel(formatTitle(Bundle.message(Resource.IAC_REALTIME_TITLE))); + @SuppressWarnings("unused") + private final JBCheckBox iacCheckbox = new JBCheckBox(Bundle.message(Resource.IAC_REALTIME_CHECKBOX)); + + @SuppressWarnings("unused") + private final ComboBox containersToolCombo = new ComboBox<>(new String[]{"docker", "podman"}); + + private GlobalSettingsState state; + private final MessageBusConnection connection; + + private final JBLabel mcpStatusLabel = new JBLabel(); + private CxLinkLabel installMcpLink; + private boolean mcpInstallInProgress; + private Timer mcpClearTimer; + + public CxOneAssistComponent() { + buildUI(); + reset(); + + connection = ApplicationManager.getApplication().getMessageBus().connect(); + connection.subscribe(SettingsListener.SETTINGS_APPLIED, new SettingsListener() { + @Override + public void settingsApplied() { + SwingUtilities.invokeLater(() -> { + LOGGER.debug("[CxOneAssist] Detected settings change, refreshing checkboxes."); + reset(); + }); + } + }); + } + + @Override + public void dispose() { + if (connection != null) { + try { + connection.dispose(); + } catch (Exception ignore) { + // ignore + } + } + } + + private void buildUI() { + // Status message label - shown at the top in red when authentication/MCP issues exist + assistMessageLabel.setForeground(JBColor.RED); + assistMessageLabel.setHorizontalAlignment(SwingConstants.LEFT); + assistMessageLabel.setVisible(false); + mainPanel.add(assistMessageLabel, "hidemode 3, growx, alignx left, wrap, gapbottom 5"); + + // OSS Realtime + mainPanel.add(ossTitle, "split 2, span"); + mainPanel.add(new JSeparator(), "growx, wrap"); + mainPanel.add(ossCheckbox, "wrap, gapbottom 10, gapleft 15"); + + // TEMPORARILY HIDDEN: Secret Detection - Will be restored in future release + // mainPanel.add(secretsTitle, "split 2, span"); + // mainPanel.add(new JSeparator(), "growx, wrap"); + // mainPanel.add(secretsCheckbox, "wrap, gapbottom 10, gapleft 15"); + + // TEMPORARILY HIDDEN: Containers Realtime - Will be restored in future release + // mainPanel.add(containersTitle, "split 2, span"); + // mainPanel.add(new JSeparator(), "growx, wrap"); + // mainPanel.add(containersCheckbox, "wrap, gapbottom 10, gapleft 15"); + + // TEMPORARILY HIDDEN: IaC Realtime - Will be restored in future release + // mainPanel.add(iacTitle, "split 2, span"); + // mainPanel.add(new JSeparator(), "growx, wrap"); + // mainPanel.add(iacCheckbox, "wrap, gapbottom 10, gapleft 15"); + + // TEMPORARILY HIDDEN: Containers management tool dropdown - Will be restored in future release + // JBLabel containersLabel = new JBLabel(formatTitle(Bundle.message(Resource.IAC_REALTIME_SCANNER_PREFIX))); + // mainPanel.add(containersLabel, "split 2, span, gaptop 10"); + // mainPanel.add(new JSeparator(), "growx, wrap"); + // mainPanel.add(new JBLabel(Bundle.message(Resource.CONTAINERS_TOOL_DESCRIPTION)), "wrap, gapleft 15"); + // containersToolCombo.setPreferredSize(new Dimension( + // containersLabel.getPreferredSize().width, + // containersToolCombo.getPreferredSize().height + // )); + // mainPanel.add(containersToolCombo, "wrap, gapleft 15"); + + // MCP Section + mainPanel.add(new JBLabel(formatTitle(Bundle.message(Resource.MCP_SECTION_TITLE))), "split 2, span, gaptop 10"); + mainPanel.add(new JSeparator(), "growx, wrap"); + mainPanel.add(new JBLabel(Bundle.message(Resource.MCP_DESCRIPTION)), "wrap, gapleft 15"); + + installMcpLink = new CxLinkLabel(Bundle.message(Resource.MCP_INSTALL_LINK), e -> installMcp()); + mcpStatusLabel.setVisible(false); + mcpStatusLabel.setBorder(new EmptyBorder(0, 20, 0, 0)); + + mainPanel.add(installMcpLink, "split 2, gapleft 15"); + mainPanel.add(mcpStatusLabel, "wrap, gapleft 15"); + + CxLinkLabel editJsonLink = new CxLinkLabel(Bundle.message(Resource.MCP_EDIT_JSON_LINK), e -> openMcpJson()); + mainPanel.add(editJsonLink, "wrap, gapleft 15"); + } + + /** + * Manual MCP installation invoked by the "Install MCP" link. + * Provides inline status feedback (successfully saved, already up to date, or auth required). + */ + private void installMcp() { + if (mcpInstallInProgress || !installMcpLink.isEnabled()) { + return; + } + + ensureState(); + GlobalSettingsSensitiveState sensitive = GlobalSettingsSensitiveState.getInstance(); + + if (!state.isAuthenticated()) { + showMcpStatus(Bundle.message(Resource.MCP_AUTH_REQUIRED), JBColor.RED); + return; + } + + String credential = state.isApiKeyEnabled() ? sensitive.getApiKey() : sensitive.getRefreshToken(); + if (credential == null || credential.isBlank()) { + showMcpStatus(Bundle.message(Resource.MCP_AUTH_REQUIRED), JBColor.RED); + return; + } + + LOGGER.debug("[CxOneAssist] Manual MCP install started."); + mcpInstallInProgress = true; + + McpInstallService.installSilentlyAsync(credential) + .whenComplete((changed, throwable) -> + SwingUtilities.invokeLater(() -> handleMcpResult(changed, throwable))); + } + + private void handleMcpResult(Boolean changed, Throwable throwable) { + mcpInstallInProgress = false; + + if (throwable != null || changed == null) { + showMcpStatus(Bundle.message(Resource.MCP_AUTH_REQUIRED), JBColor.RED); + } else if (changed) { + showMcpStatus(Bundle.message(Resource.MCP_CONFIG_SAVED), JBColor.GREEN); + } else { + showMcpStatus(Bundle.message(Resource.MCP_CONFIG_UP_TO_DATE), JBColor.GREEN); + } + } + + private void showMcpStatus(String message, Color color) { + mcpStatusLabel.setText(message); + mcpStatusLabel.setForeground(color); + mcpStatusLabel.setVisible(true); + + if (mcpClearTimer != null) { + mcpClearTimer.stop(); + } + mcpClearTimer = new Timer(5000, e -> { + mcpStatusLabel.setVisible(false); + mcpStatusLabel.setText(""); + }); + mcpClearTimer.setRepeats(false); + mcpClearTimer.start(); + } + + /** Opens (and creates if necessary) the Copilot MCP configuration file then closes the settings dialog. */ + private void openMcpJson() { + // Apply settings if modified, then close dialog window + try { + if (isModified()) { + apply(); + } + } catch (Exception ex) { + LOGGER.warn("[CxOneAssist] Failed applying settings before closing dialog", ex); + } + java.awt.Window w = SwingUtilities.getWindowAncestor(mainPanel); + if (w != null) { + w.dispose(); + } + + Project[] open = ProjectManager.getInstance().getOpenProjects(); + Project project = (open.length > 0) ? open[0] : ProjectManager.getInstance().getDefaultProject(); + if (project == null) { + LOGGER.warn("[CxOneAssist] No project available to open mcp.json"); + return; + } + + java.nio.file.Path path; + try { + path = McpSettingsInjector.getMcpJsonPath(); + } catch (Exception ex) { + LOGGER.warn("[CxOneAssist] Failed resolving MCP config path", ex); + return; + } + if (path == null) { + LOGGER.warn("[CxOneAssist] MCP config path is null"); + return; + } + + VirtualFile vf = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path); + if (vf != null && vf.exists()) { + FileEditorManager.getInstance(project).openFile(vf, true); + } else { + LOGGER.warn("[CxOneAssist] mcp.json not found at: " + path); + showMcpStatus(Bundle.message(Resource.MCP_NOT_FOUND), JBColor.RED); + } + } + + @Override + public JPanel getMainPanel() { + return mainPanel; + } + + @Override + public boolean isModified() { + ensureState(); + return ossCheckbox.isSelected() != state.isOssRealtime(); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // || secretsCheckbox.isSelected() != state.isSecretDetectionRealtime() + // || containersCheckbox.isSelected() != state.isContainersRealtime() + // || iacCheckbox.isSelected() != state.isIacRealtime() + // || !Objects.equals(String.valueOf(containersToolCombo.getSelectedItem()), state.getContainersTool()); + } + + @Override + public void apply() { + ensureState(); + + // Apply current UI selections to active scanner settings + state.setOssRealtime(ossCheckbox.isSelected()); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // state.setSecretDetectionRealtime(secretsCheckbox.isSelected()); + // state.setContainersRealtime(containersCheckbox.isSelected()); + // state.setIacRealtime(iacCheckbox.isSelected()); + // state.setContainersTool(String.valueOf(containersToolCombo.getSelectedItem())); + + // Save user preferences to preserve choices across MCP enable/disable cycles + // This ensures that when MCP is temporarily disabled and then re-enabled, + // the user's individual scanner preferences are restored instead of defaulting to "all enabled" + state.setUserPreferences( + ossCheckbox.isSelected(), + state.isSecretDetectionRealtime(), // Use current state for hidden fields + state.isContainersRealtime(), // Use current state for hidden fields + state.isIacRealtime() // Use current state for hidden fields + ); + + + GlobalSettingsState.getInstance().apply(state); + + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(SettingsListener.SETTINGS_APPLIED) + .settingsApplied(); + } + + @Override + public void reset() { + state = GlobalSettingsState.getInstance(); + ossCheckbox.setSelected(state.isOssRealtime()); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // secretsCheckbox.setSelected(state.isSecretDetectionRealtime()); + // containersCheckbox.setSelected(state.isContainersRealtime()); + // iacCheckbox.setSelected(state.isIacRealtime()); + // containersToolCombo.setSelectedItem( + // state.getContainersTool() == null || state.getContainersTool().isBlank() + // ? "docker" + // : state.getContainersTool() + // ); + updateAssistState(); + } + + private void updateAssistState() { + ensureState(); + boolean authenticated = state.isAuthenticated(); + + if (!authenticated) { + // If not authenticated, immediately show message, disable controls, and uncheck scanners + ossCheckbox.setEnabled(false); + ossCheckbox.setSelected(false); + installMcpLink.setEnabled(false); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // secretsCheckbox.setEnabled(false); + // secretsCheckbox.setSelected(false); + // containersCheckbox.setEnabled(false); + // containersCheckbox.setSelected(false); + // iacCheckbox.setEnabled(false); + // iacCheckbox.setSelected(false); + + assistMessageLabel.setText(Bundle.message(Resource.CXONE_ASSIST_LOGIN_MESSAGE)); + assistMessageLabel.setForeground(JBColor.RED); + assistMessageLabel.setVisible(true); + return; + } + + // Check if MCP status hasn't been checked yet (upgrade scenario) + if (!state.isMcpStatusChecked()) { + checkAndUpdateMcpStatusAsync(); + return; // UI will be updated when async check completes + } + + // If authenticated, use the cached MCP status (determined during authentication) + boolean mcpEnabled = state.isMcpEnabled(); + updateUIWithMcpStatus(mcpEnabled); + } + + + + private void updateUIWithMcpStatus(boolean mcpEnabled) { + ossCheckbox.setEnabled(mcpEnabled); + installMcpLink.setEnabled(mcpEnabled); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // secretsCheckbox.setEnabled(mcpEnabled); + // containersCheckbox.setEnabled(mcpEnabled); + // iacCheckbox.setEnabled(mcpEnabled); + + if (!mcpEnabled) { + ensureState(); + + // Preserve current scanner settings as user preferences before disabling + if (!state.isUserPreferencesSet()) { + state.saveCurrentSettingsAsUserPreferences(); + LOGGER.debug("[CxOneAssist] Preserved scanner settings as user preferences (MCP disabled)"); + } + + // When MCP is disabled, uncheck all scanner checkboxes to prevent realtime scanning + ossCheckbox.setSelected(false); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // secretsCheckbox.setSelected(false); + // containersCheckbox.setSelected(false); + // iacCheckbox.setSelected(false); + + boolean settingsChanged = false; + if (state.isOssRealtime()) { + state.setOssRealtime(false); + settingsChanged = true; + } + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // if (state.isSecretDetectionRealtime()) { + // state.setSecretDetectionRealtime(false); + // settingsChanged = true; + // } + // if (state.isContainersRealtime()) { + // state.setContainersRealtime(false); + // settingsChanged = true; + // } + // if (state.isIacRealtime()) { + // state.setIacRealtime(false); + // settingsChanged = true; + // } + + if (settingsChanged) { + GlobalSettingsState.getInstance().apply(state); + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(SettingsListener.SETTINGS_APPLIED) + .settingsApplied(); + } + + assistMessageLabel.setText(Bundle.message(Resource.CXONE_ASSIST_MCP_DISABLED_MESSAGE)); + assistMessageLabel.setForeground(JBColor.RED); + assistMessageLabel.setVisible(true); + } else { + // MCP is enabled - restore user preferences if available + ensureState(); + if (state.isUserPreferencesSet()) { + boolean preferencesApplied = state.applyUserPreferencesToRealtimeSettings(); + if (preferencesApplied) { + LOGGER.debug("[CxOneAssist] Restored user preferences for realtime scanners"); + GlobalSettingsState.getInstance().apply(state); + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(SettingsListener.SETTINGS_APPLIED) + .settingsApplied(); + } + } + + // Update UI to reflect current scanner state (including any restored preferences) + ossCheckbox.setSelected(state.isOssRealtime()); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // secretsCheckbox.setSelected(state.isSecretDetectionRealtime()); + + assistMessageLabel.setVisible(false); + assistMessageLabel.setText(""); // Clear any previous message + } + } + + /** + * Asynchronously checks MCP status when it hasn't been checked before. + * This handles the upgrade scenario where a user is already authenticated + * but using a newer plugin version that includes MCP status checking. + */ + private void checkAndUpdateMcpStatusAsync() { + // Show loading message while checking + assistMessageLabel.setText(Bundle.message(Resource.CHECKING_MCP_STATUS)); + assistMessageLabel.setForeground(JBColor.GRAY); + assistMessageLabel.setVisible(true); + + // Disable controls while checking + ossCheckbox.setEnabled(false); + installMcpLink.setEnabled(false); + // TEMPORARILY HIDDEN: Other realtime scanners - Will be restored in future release + // secretsCheckbox.setEnabled(false); + + CompletableFuture.supplyAsync(() -> { + try { + GlobalSettingsState currentState = GlobalSettingsState.getInstance(); + GlobalSettingsSensitiveState currentSensitiveState = GlobalSettingsSensitiveState.getInstance(); + return com.checkmarx.intellij.commands.TenantSetting.isAiMcpServerEnabled(currentState, currentSensitiveState); + } catch (Exception ex) { + LOGGER.warn("Failed to check MCP status during upgrade scenario", ex); + return false; // Default to disabled on error + } + }).whenCompleteAsync((mcpEnabled, throwable) -> { + SwingUtilities.invokeLater(() -> { + ensureState(); + + // For future upgrade scenarios: preserve existing scanner configuration as user preferences + // This prevents plugin updates from losing the user's current scanner settings + if (!state.isUserPreferencesSet()) { + state.saveCurrentSettingsAsUserPreferences(); + LOGGER.debug("[CxOneAssist] Preserved existing scanner configuration during upgrade"); + } + + // Update state with the determined MCP status + state.setMcpEnabled(mcpEnabled); + state.setMcpStatusChecked(true); + GlobalSettingsState.getInstance().apply(state); + + // Update UI based on MCP availability (will restore preferences if MCP enabled) + updateUIWithMcpStatus(mcpEnabled); + + if (throwable != null) { + LOGGER.warn("Error during MCP status check", throwable); + } + }); + }); + } + + private void ensureState() { + // Always get fresh state to ensure we have the latest MCP configuration + state = GlobalSettingsState.getInstance(); + } + + private static String formatTitle(String raw) { + if (raw == null) { + return ""; + } + int idx = raw.indexOf(':'); + if (idx < 0 || idx == raw.length() - 1) { + return String.format(Constants.HTML_WRAPPER_FORMAT, raw); + } + String before = raw.substring(0, idx + 1); + String after = raw.substring(idx + 1).trim(); + String html = String.format("%s %s", before, after); + return String.format(Constants.HTML_WRAPPER_FORMAT, html); + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/settings/global/CxOneAssistConfigurable.java b/src/main/java/com/checkmarx/intellij/settings/global/CxOneAssistConfigurable.java new file mode 100644 index 00000000..8baaabd0 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/settings/global/CxOneAssistConfigurable.java @@ -0,0 +1,65 @@ +package com.checkmarx.intellij.settings.global; + +import com.checkmarx.intellij.Bundle; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.settings.SettingsComponent; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.SearchableConfigurable; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +/** + * Settings child node under "Checkmarx One" for CxOne Assist realtime features. + */ +public class CxOneAssistConfigurable implements SearchableConfigurable, Configurable.NoScroll { + + private SettingsComponent settingsComponent; + + @Override + public @NotNull @NonNls String getId() { + // Place under the same search group; ID should be unique + return Constants.GLOBAL_SETTINGS_ID + ".assist"; + } + + @Override + public @Nullable @NonNls String getHelpTopic() { + return getId(); + } + + @Override + public @NotNull @Nls String getDisplayName() { + return Bundle.message(Resource.CXONE_ASSIST_TITLE); + } + + @Override + public @Nullable JComponent createComponent() { + settingsComponent = new CxOneAssistComponent(); + return settingsComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + return settingsComponent != null && settingsComponent.isModified(); + } + + @Override + public void apply() throws ConfigurationException { + if (settingsComponent != null) { + settingsComponent.apply(); + } + } + + @Override + public void reset() { + if (settingsComponent != null) { + settingsComponent.reset(); + } + SearchableConfigurable.super.reset(); + } +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsComponent.java b/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsComponent.java index 923d856d..83696edf 100644 --- a/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsComponent.java +++ b/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsComponent.java @@ -7,10 +7,12 @@ import com.checkmarx.intellij.Utils; import com.checkmarx.intellij.commands.Authentication; import com.checkmarx.intellij.components.CxLinkLabel; +import com.checkmarx.intellij.devassist.configuration.mcp.McpSettingsInjector; import com.checkmarx.intellij.service.AscaService; import com.checkmarx.intellij.service.AuthService; import com.checkmarx.intellij.settings.SettingsComponent; import com.checkmarx.intellij.settings.SettingsListener; +import com.checkmarx.intellij.devassist.ui.WelcomeDialog; import com.checkmarx.intellij.util.InputValidator; import com.intellij.notification.NotificationType; import com.intellij.openapi.application.ApplicationManager; @@ -30,6 +32,10 @@ import net.miginfocom.swing.MigLayout; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; +import com.intellij.ide.DataManager; +import com.intellij.openapi.actionSystem.DataContext; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ex.Settings; import javax.swing.*; import javax.swing.event.DocumentEvent; @@ -214,11 +220,43 @@ private void setValidationResult() { } } + /** + * Creates a GlobalSettingsState object from the current UI field values. + * + * IMPORTANT: This method must preserve ALL existing state fields that are not directly + * managed by this UI panel, including user preferences for realtime scanners. + * Failure to copy these fields will result in them being reset to default values + * when the state is applied, causing user preferences to be lost. + */ private GlobalSettingsState getStateFromFields() { GlobalSettingsState state = new GlobalSettingsState(); + + // Fields directly managed by this UI panel state.setAdditionalParameters(additionalParametersField.getText().trim()); state.setAsca(ascaCheckBox.isSelected()); state.setApiKeyEnabled(apiKeyRadio.isSelected()); + + // Preserve all other state fields from the current settings + if (SETTINGS_STATE != null) { + // Realtime scanner active states + state.setOssRealtime(SETTINGS_STATE.isOssRealtime()); + state.setSecretDetectionRealtime(SETTINGS_STATE.isSecretDetectionRealtime()); + state.setContainersRealtime(SETTINGS_STATE.isContainersRealtime()); + state.setIacRealtime(SETTINGS_STATE.isIacRealtime()); + state.setContainersTool(SETTINGS_STATE.getContainersTool()); + + // MCP and dialog state + state.setWelcomeShown(SETTINGS_STATE.isWelcomeShown()); + state.setMcpEnabled(SETTINGS_STATE.isMcpEnabled()); + state.setMcpStatusChecked(SETTINGS_STATE.isMcpStatusChecked()); + + // User preferences for realtime scanners - CRITICAL for preference preservation + state.setUserPreferencesSet(SETTINGS_STATE.isUserPreferencesSet()); + state.setUserPrefOssRealtime(SETTINGS_STATE.getUserPrefOssRealtime()); + state.setUserPrefSecretDetectionRealtime(SETTINGS_STATE.getUserPrefSecretDetectionRealtime()); + state.setUserPrefContainersRealtime(SETTINGS_STATE.getUserPrefContainersRealtime()); + state.setUserPrefIacRealtime(SETTINGS_STATE.getUserPrefIacRealtime()); + } return state; } @@ -246,29 +284,127 @@ private void addValidateConnectionListener() { } Authentication.validateConnection(getStateFromFields(), getSensitiveStateFromFields()); sessionConnected = true; - SwingUtilities.invokeLater(() -> { - setValidationResult(Bundle.message(Resource.VALIDATE_SUCCESS), JBColor.GREEN); - logoutButton.setEnabled(true); - connectButton.setEnabled(false); - setFieldsEditable(false); - SETTINGS_STATE.setAuthenticated(true); - SETTINGS_STATE.setLastValidationSuccess(true); - SETTINGS_STATE.setValidationMessage(Bundle.message(Resource.VALIDATE_SUCCESS)); - apply(); // Persist the state immediately - logoutButton.requestFocusInWindow(); - }); + SwingUtilities.invokeLater(() -> onAuthSuccessApiKey()); LOGGER.info(Bundle.message(Resource.VALIDATE_SUCCESS)); } catch (Exception e) { handleConnectionFailure(e); } }); } else { - // Proceed for OAuth authentication proceedOAuthAuthentication(); } }); } + private void onAuthSuccessApiKey() { + // Set basic authentication success state + setValidationResult(Bundle.message(Resource.VALIDATE_SUCCESS), JBColor.GREEN); + logoutButton.setEnabled(true); + connectButton.setEnabled(false); + setFieldsEditable(false); + SETTINGS_STATE.setAuthenticated(true); + SETTINGS_STATE.setLastValidationSuccess(true); + SETTINGS_STATE.setValidationMessage(Bundle.message(Resource.VALIDATE_SUCCESS)); + logoutButton.requestFocusInWindow(); + + // Complete post-authentication setup + completeAuthenticationSetup(String.valueOf(apiKeyField.getPassword())); + } + + /** + * Common post-authentication setup logic for both API key and OAuth authentication. + * Checks MCP server status, configures realtime scanners, and shows welcome dialog. + * + * @param credential The credential to use for MCP installation (API key or refresh token) + */ + private void completeAuthenticationSetup(String credential) { + // Check MCP server status using current authentication credentials + boolean mcpServerEnabled = false; + try { + mcpServerEnabled = com.checkmarx.intellij.commands.TenantSetting.isAiMcpServerEnabled( + getStateFromFields(), getSensitiveStateFromFields()); + } catch (Exception ex) { + LOGGER.warn("Failed MCP server check", ex); + } + + // Determine if MCP status has actually changed to avoid resetting user preferences on simple re-authentication + boolean previousMcpEnabled = SETTINGS_STATE.isMcpEnabled(); + boolean mcpStatusPreviouslyChecked = SETTINGS_STATE.isMcpStatusChecked(); + boolean mcpStatusChanged = mcpStatusPreviouslyChecked && (previousMcpEnabled != mcpServerEnabled); + + // Store MCP status and authentication state + SETTINGS_STATE.setMcpEnabled(mcpServerEnabled); + SETTINGS_STATE.setMcpStatusChecked(true); + apply(); + + // Configure realtime scanners based on MCP status - only modify settings when necessary to preserve user preferences during routine re-authentication + if (!mcpStatusPreviouslyChecked) { + // First time checking MCP status (new user or plugin upgrade scenario) + if (mcpServerEnabled) { + autoEnableAllRealtimeScanners(); // Enable scanners with preference detection + installMcpAsync(credential); + } else { + disableAllRealtimeScanners(); // Disable scanners while preserving preferences + } + LOGGER.debug("[Auth] Initial MCP status setup completed for MCP enabled: " + mcpServerEnabled); + } else if (mcpStatusChanged) { + // MCP status has changed since last authentication - update scanner configuration + if (mcpServerEnabled) { + LOGGER.debug("[Auth] MCP re-enabled - restoring user preferences"); + autoEnableAllRealtimeScanners(); // Restore user preferences + installMcpAsync(credential); + } else { + LOGGER.debug("[Auth] MCP disabled - preserving user preferences and disabling scanners"); + disableAllRealtimeScanners(); // Preserve preferences before disabling + } + } else { + // MCP status unchanged - preserve existing scanner settings and user preferences + if (mcpServerEnabled) { + installMcpAsync(credential); // Ensure MCP config is up to date + LOGGER.debug("[Auth] MCP unchanged (enabled) - user preferences preserved"); + } else { + LOGGER.debug("[Auth] MCP unchanged (disabled) - user preferences preserved"); + } + } + + showWelcomeDialog(mcpServerEnabled); + } + + private void installMcpAsync(String credential) { + CompletableFuture.supplyAsync(() -> { + try { + // Returns Boolean.TRUE if MCP modified, Boolean.FALSE if already up-to-date + return McpSettingsInjector.installForCopilot(credential); + } catch (Exception ex) { + return ex; + } + }).thenAccept(result -> SwingUtilities.invokeLater(() -> { + if (result instanceof Exception) { + Utils.showNotification( + Bundle.message(Resource.MCP_NOTIFICATION_TITLE), + Bundle.message(Resource.MCP_AUTH_REQUIRED), + NotificationType.ERROR, + project + ); + LOGGER.warn("MCP install error", (Exception) result); + } else if (Boolean.TRUE.equals(result)) { + Utils.showNotification( + Bundle.message(Resource.MCP_NOTIFICATION_TITLE), + Bundle.message(Resource.MCP_CONFIG_SAVED), + NotificationType.INFORMATION, + project + ); + } else if (Boolean.FALSE.equals(result)) { + Utils.showNotification( + Bundle.message(Resource.MCP_NOTIFICATION_TITLE), + Bundle.message(Resource.MCP_CONFIG_UP_TO_DATE), + NotificationType.INFORMATION, + project + ); + } + })); + } + /** * Proceed for authentication using OAUth */ @@ -333,14 +469,11 @@ private void proceedOAuthAuthentication() { private void handleOAuthSuccess(Map refreshTokenDetails) { SwingUtilities.invokeLater(() -> { sessionConnected = true; - setValidationResult(Bundle.message(Resource.VALIDATE_SUCCESS), JBColor.GREEN); validateResult.setVisible(true); - logoutButton.setEnabled(true); connectButton.setEnabled(false); setFieldsEditable(false); - SETTINGS_STATE.setAuthenticated(true); SETTINGS_STATE.setValidationInProgress(false); SETTINGS_STATE.setValidationExpiry(null); @@ -348,8 +481,10 @@ private void handleOAuthSuccess(Map refreshTokenDetails) { SETTINGS_STATE.setValidationMessage(Bundle.message(Resource.VALIDATE_SUCCESS)); SENSITIVE_SETTINGS_STATE.setRefreshToken(refreshTokenDetails.get(Constants.AuthConstants.REFRESH_TOKEN).toString()); SETTINGS_STATE.setRefreshTokenExpiry(refreshTokenDetails.get(Constants.AuthConstants.REFRESH_TOKEN_EXPIRY).toString()); - apply(); - notifyAuthSuccess(); // Even if panel is not showing now + notifyAuthSuccess(); + + // Complete post-authentication setup + completeAuthenticationSetup(SENSITIVE_SETTINGS_STATE.getRefreshToken()); }); } @@ -374,6 +509,15 @@ private void handleOAuthFailure(String error) { }); } + private void showWelcomeDialog(boolean mcpEnabled) { + try { + WelcomeDialog dlg = new WelcomeDialog(project, mcpEnabled); + dlg.show(); + } catch (Exception ex) { + LOGGER.warn("Failed to show welcome dialog", ex); + } + } + private void handleConnectionFailure(Exception e) { SwingUtilities.invokeLater(() -> { setValidationResult(Bundle.message(Resource.VALIDATE_ERROR), JBColor.RED); @@ -481,6 +625,22 @@ private void buildGUI() { addSectionHeader(Resource.ASCA_DESCRIPTION, false); mainPanel.add(ascaCheckBox); mainPanel.add(ascaInstallationMsg, "gapleft 5, wrap"); + + // === CxOne Assist link section === + CxLinkLabel assistLink = new CxLinkLabel( + Bundle.message(Resource.GO_TO_CXONE_ASSIST_LINK), + e -> { + DataContext context = DataManager.getInstance().getDataContext(mainPanel); + Settings settings = context.getData(Settings.KEY); + if (settings == null) return; + + Configurable configurable = settings.find("settings.ast.assist"); + if (configurable instanceof CxOneAssistConfigurable) { + settings.select(configurable); + } + } + ); + mainPanel.add(assistLink, "wrap, gapleft 5, gaptop 10"); } private void setupFields() { @@ -607,6 +767,18 @@ private void addLogoutListener() { if (userChoice == Messages.YES) { setLogoutState(); notifyLogout(); + + // Ensure only the Checkmarx MCP entry is removed and log any issues. + java.util.concurrent.CompletableFuture.runAsync(() -> { + try { + boolean removed = McpSettingsInjector.uninstallFromCopilot(); + if (!removed) { + LOGGER.debug("Logout completed, but no MCP entry was present to remove."); + } + } catch (Exception ex) { + LOGGER.warn("Failed to remove Checkmarx MCP entry on logout.", ex); + } + }); } // else: Do nothing (user clicked Cancel) }); @@ -624,6 +796,7 @@ private void setLogoutState() { setFieldsEditable(true); updateConnectButtonState(); SETTINGS_STATE.setAuthenticated(false); // Update authentication state + // Don't clear MCP status on logout - keep it for next login SETTINGS_STATE.setValidationMessage(Bundle.message(Resource.LOGOUT_SUCCESS)); SETTINGS_STATE.setLastValidationSuccess(true); if (!SETTINGS_STATE.isApiKeyEnabled()) { // if oauth login is enabled @@ -640,13 +813,16 @@ private void setSessionExpired() { connectButton.setEnabled(true); logoutButton.setEnabled(false); setFieldsEditable(true); - updateConnectButtonState(); - SETTINGS_STATE.setAuthenticated(false); // Update authentication state + + // Clear authentication and MCP status + SETTINGS_STATE.setAuthenticated(false); + SETTINGS_STATE.setMcpEnabled(false); + SETTINGS_STATE.setMcpStatusChecked(false); if (!SETTINGS_STATE.isApiKeyEnabled()) { // if oauth login is enabled SENSITIVE_SETTINGS_STATE.deleteRefreshToken(); } apply(); - updateConnectButtonState(); // Ensure the Connect button state is updated + updateConnectButtonState(); // Update button state after all changes } @@ -809,4 +985,71 @@ private boolean isValidateTimeExpired() { } return false; } + + /** + * Configures realtime scanners when MCP is enabled, with intelligent preference handling. + * For existing users: restores their individual scanner preferences + * For new users: enables all scanners as defaults and saves as initial preferences + */ + private void autoEnableAllRealtimeScanners() { + GlobalSettingsState st = GlobalSettingsState.getInstance(); + boolean changed = false; + + // Priority 1: Restore existing user preferences if available + if (st.isUserPreferencesSet()) { + changed = st.applyUserPreferencesToRealtimeSettings(); + if (changed) { + LOGGER.debug("[Auth] Restored user preferences for realtime scanners"); + apply(); + return; + } else { + LOGGER.debug("[Auth] User preferences already applied to realtime scanners"); + return; + } + } + + // Priority 2: For new users, enable all scanners as sensible defaults + if (!st.isOssRealtime()) { st.setOssRealtime(true); changed = true; } + if (!st.isSecretDetectionRealtime()) { st.setSecretDetectionRealtime(true); changed = true; } + if (!st.isContainersRealtime()) { st.setContainersRealtime(true); changed = true; } + if (!st.isIacRealtime()) { st.setIacRealtime(true); changed = true; } + + if (changed) { + // Save the "all enabled" defaults as initial user preferences for future preservation + st.saveCurrentSettingsAsUserPreferences(); + LOGGER.debug("[Auth] Enabled all scanners for new user and saved as initial preferences"); + apply(); + } else { + LOGGER.debug("[Auth] All realtime scanners already enabled"); + } + } + + /** + * Disables all realtime scanners when MCP is not available, while preserving user preferences. + * The user's individual scanner choices are saved before disabling, ensuring they can be + * restored when MCP becomes available again. + */ + private void disableAllRealtimeScanners() { + GlobalSettingsState st = GlobalSettingsState.getInstance(); + + // Preserve current scanner settings as user preferences before disabling + if (!st.isUserPreferencesSet()) { + st.saveCurrentSettingsAsUserPreferences(); + LOGGER.debug("[Auth] Saved current scanner settings as user preferences before disabling"); + } + + // Disable all scanners for security (MCP not available) + boolean changed = false; + if (st.isOssRealtime()) { st.setOssRealtime(false); changed = true; } + if (st.isSecretDetectionRealtime()) { st.setSecretDetectionRealtime(false); changed = true; } + if (st.isContainersRealtime()) { st.setContainersRealtime(false); changed = true; } + if (st.isIacRealtime()) { st.setIacRealtime(false); changed = true; } + + if (changed) { + LOGGER.debug("[Auth] Disabled all realtime scanners while preserving user preferences"); + apply(); + } else { + LOGGER.debug("[Auth] Realtime scanners already disabled"); + } + } } \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsState.java b/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsState.java index 189cfd95..8d2a9cb3 100644 --- a/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsState.java +++ b/src/main/java/com/checkmarx/intellij/settings/global/GlobalSettingsState.java @@ -67,6 +67,39 @@ public static GlobalSettingsState getInstance() { @Attribute("validationInProgress") private boolean validationInProgress = false; + // --- CxOne Assist realtime feature flags and options --- + private boolean ossRealtime = false; + private boolean secretDetectionRealtime = false; + private boolean containersRealtime = false; + private boolean iacRealtime = false; + private String containersTool = "docker"; + @Attribute("mcpEnabled") + private boolean mcpEnabled = false; + @Attribute("mcpStatusChecked") + private boolean mcpStatusChecked = false; + @Attribute("welcomeShown") + private boolean welcomeShown = false; + + /** + * User preferences for realtime scanners (preserved across MCP enable/disable cycles) --- + * These fields store the user's individual scanner preferences and are preserved even when MCP is disabled at the tenant level. + * When MCP is re-enabled, these preferences are restored instead of defaulting to "all enabled", ensuring user choice is respected. + */ + @Attribute("userPreferencesSet") + private boolean userPreferencesSet = false; + + @Attribute("userPrefOssRealtime") + private boolean userPrefOssRealtime = false; + + @Attribute("userPrefSecretDetectionRealtime") + private boolean userPrefSecretDetectionRealtime = false; + + @Attribute("userPrefContainersRealtime") + private boolean userPrefContainersRealtime = false; + + @Attribute("userPrefIacRealtime") + private boolean userPrefIacRealtime = false; + @Override public @Nullable GlobalSettingsState getState() { return this; @@ -77,8 +110,96 @@ public void loadState(@NotNull GlobalSettingsState state) { XmlSerializerUtil.copyBean(state, this); } + /** + * Applies the given state to this instance, copying all fields including user preferences. + * This ensures that user preferences are preserved during state transitions. + */ public void apply(@NotNull GlobalSettingsState state) { loadState(state); } -} + // --- User Preference Methods --- + + /** + * Sets user preferences for realtime scanners, preserving individual choices across MCP enable/disable cycles. + * These preferences are stored separately from the active scanner states and are restored when MCP is re-enabled. + * + * @param ossRealtime OSS scanner preference + * @param secretDetectionRealtime Secret Detection scanner preference + * @param containersRealtime Containers scanner preference + * @param iacRealtime Infrastructure as Code scanner preference + */ + public void setUserPreferences(boolean ossRealtime, boolean secretDetectionRealtime, + boolean containersRealtime, boolean iacRealtime) { + this.userPrefOssRealtime = ossRealtime; + this.userPrefSecretDetectionRealtime = secretDetectionRealtime; + this.userPrefContainersRealtime = containersRealtime; + this.userPrefIacRealtime = iacRealtime; + this.userPreferencesSet = true; + } + + + /** + * Applies stored user preferences to the active realtime scanner settings. + * This is called when MCP is enabled to restore the user's individual scanner choices + * instead of defaulting to "all enabled". + * + * @return true if any settings were changed, false if preferences were already applied or not set + */ + public boolean applyUserPreferencesToRealtimeSettings() { + if (!userPreferencesSet) { + return false; // No user preferences stored + } + + boolean changed = false; + if (ossRealtime != userPrefOssRealtime) { + ossRealtime = userPrefOssRealtime; + changed = true; + } + if (secretDetectionRealtime != userPrefSecretDetectionRealtime) { + secretDetectionRealtime = userPrefSecretDetectionRealtime; + changed = true; + } + if (containersRealtime != userPrefContainersRealtime) { + containersRealtime = userPrefContainersRealtime; + changed = true; + } + if (iacRealtime != userPrefIacRealtime) { + iacRealtime = userPrefIacRealtime; + changed = true; + } + + return changed; + } + + /** + * Saves the current realtime scanner settings as user preferences. + * This is typically called before disabling scanners when MCP becomes unavailable, + * ensuring the user's choices can be restored later. + */ + public void saveCurrentSettingsAsUserPreferences() { + setUserPreferences(ossRealtime, secretDetectionRealtime, containersRealtime, iacRealtime); + } + + /** + * Checks if the user has set any custom preferences that differ from the default "all enabled" state. + * This helps distinguish between new users (who should get defaults) and existing users + * (whose custom choices should be preserved). + * + * @return true if user has any scanners disabled in their preferences + */ + public boolean hasCustomUserPreferences() { + return userPreferencesSet && ( + !userPrefOssRealtime || + !userPrefSecretDetectionRealtime || + !userPrefContainersRealtime || + !userPrefIacRealtime + ); + } + + // Getters for user preferences (for debugging and verification) + public boolean getUserPrefOssRealtime() { return userPrefOssRealtime; } + public boolean getUserPrefSecretDetectionRealtime() { return userPrefSecretDetectionRealtime; } + public boolean getUserPrefContainersRealtime() { return userPrefContainersRealtime; } + public boolean getUserPrefIacRealtime() { return userPrefIacRealtime; } +} diff --git a/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowFactory.java b/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowFactory.java index 94fadfad..df67c526 100644 --- a/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowFactory.java +++ b/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowFactory.java @@ -1,10 +1,13 @@ package com.checkmarx.intellij.tool.window; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.devassist.ui.findings.window.CxFindingsWindow; import com.intellij.openapi.project.DumbAware; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Disposer; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentManager; import org.jetbrains.annotations.NotNull; @@ -22,7 +25,17 @@ public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { final CxToolWindowPanel cxToolWindowPanel = new CxToolWindowPanel(project); ContentManager contentManager = toolWindow.getContentManager(); - contentManager.addContent(contentManager.getFactory().createContent(cxToolWindowPanel, null, false)); + // First tab + contentManager.addContent( + contentManager.getFactory().createContent(cxToolWindowPanel, "Scan Results", false) + ); + // Second tab + Content customProblemContent = contentManager.getFactory().createContent(null, Constants.RealTimeConstants.DEVASSIST_TAB, false); + final CxFindingsWindow vulnerabilityToolWindow = new CxFindingsWindow(project, customProblemContent); + customProblemContent.setComponent(vulnerabilityToolWindow); + contentManager.addContent(customProblemContent); + Disposer.register(project, cxToolWindowPanel); + Disposer.register(project, vulnerabilityToolWindow); } } diff --git a/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowPanel.java b/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowPanel.java index a8659a37..ad120ba5 100644 --- a/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowPanel.java +++ b/src/main/java/com/checkmarx/intellij/tool/window/CxToolWindowPanel.java @@ -6,6 +6,8 @@ import com.checkmarx.intellij.commands.results.obj.ResultGetState; import com.checkmarx.intellij.components.TreeUtils; import com.checkmarx.intellij.project.ProjectResultsService; +import com.checkmarx.intellij.devassist.configuration.ScannerLifeCycleManager; +import com.checkmarx.intellij.devassist.registry.ScannerRegistry; import com.checkmarx.intellij.service.StateService; import com.checkmarx.intellij.settings.SettingsListener; import com.checkmarx.intellij.settings.global.GlobalSettingsComponent; @@ -96,10 +98,12 @@ public CxToolWindowPanel(@NotNull Project project) { this.project = project; this.projectResultsService = project.getService(ProjectResultsService.class); - Runnable r = () -> { if (new GlobalSettingsComponent().isValid()) { drawMainPanel(); + ScannerRegistry registry = project.getService(ScannerRegistry.class); + registry.registerAllScanners(project); + } else { drawAuthPanel(); projectResultsService.indexResults(project, Results.emptyResults); @@ -116,7 +120,6 @@ public CxToolWindowPanel(@NotNull Project project) { r.run(); } - /** * Creates the main panel UI for results. */ @@ -169,6 +172,7 @@ private void drawMainPanel() { /** * Draw a panel with logo and a button to settings, when settings are invalid + * */ private void drawAuthPanel() { removeAll(); @@ -465,6 +469,7 @@ private void resetResultWindow() { scanTreeSplitter.setSecondComponent(simplePanel()); } + public interface CxRefreshHandler { void refresh(); } diff --git a/src/main/java/com/checkmarx/intellij/tool/window/Severity.java b/src/main/java/com/checkmarx/intellij/tool/window/Severity.java index bf4c1933..6bd2a611 100644 --- a/src/main/java/com/checkmarx/intellij/tool/window/Severity.java +++ b/src/main/java/com/checkmarx/intellij/tool/window/Severity.java @@ -2,7 +2,6 @@ import com.checkmarx.intellij.CxIcons; import com.checkmarx.intellij.tool.window.actions.filter.Filterable; -import com.intellij.icons.AllIcons; import lombok.Getter; import javax.swing.*; @@ -14,14 +13,15 @@ */ @Getter public enum Severity implements Filterable { - CRITICAL(CxIcons.CRITICAL), - HIGH(CxIcons.HIGH), - MEDIUM(CxIcons.MEDIUM), - LOW(CxIcons.LOW), + MALICIOUS(CxIcons.Medium.MALICIOUS), + CRITICAL(CxIcons.Medium.CRITICAL), + HIGH(CxIcons.Medium.HIGH), + MEDIUM(CxIcons.Medium.MEDIUM), + LOW(CxIcons.Medium.LOW), INFO(CxIcons.INFO), ; - public static final Set DEFAULT_SEVERITIES = Set.of(CRITICAL, HIGH, MEDIUM); + public static final Set DEFAULT_SEVERITIES = Set.of(MALICIOUS,CRITICAL, HIGH, MEDIUM); private final Icon icon; @@ -44,4 +44,4 @@ public static Severity fromID(String id) { } throw new IllegalArgumentException("Invalid ID for severity"); } -} +} \ No newline at end of file diff --git a/src/main/java/com/checkmarx/intellij/tool/window/actions/CollapseAllAction.java b/src/main/java/com/checkmarx/intellij/tool/window/actions/CollapseAllAction.java index b55a4d09..5409536d 100644 --- a/src/main/java/com/checkmarx/intellij/tool/window/actions/CollapseAllAction.java +++ b/src/main/java/com/checkmarx/intellij/tool/window/actions/CollapseAllAction.java @@ -2,36 +2,64 @@ import com.checkmarx.intellij.Bundle; import com.checkmarx.intellij.Resource; -import com.checkmarx.intellij.tool.window.CxToolWindowPanel; -import com.intellij.openapi.actionSystem.ActionUpdateThread; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.ui.content.Content; import org.jetbrains.annotations.NotNull; -import java.util.Optional; +import javax.swing.*; +import java.awt.*; /** * Action to collapse the results tree. */ @SuppressWarnings("ComponentNotRegistered") -public class CollapseAllAction extends AnAction implements CxToolWindowAction { - +public class CollapseAllAction extends AnAction { public CollapseAllAction() { super(Bundle.messagePointer(Resource.COLLAPSE_ALL_ACTION)); } - - /** - * {@inheritDoc} - * Trigger a collapse all in the tree for the current project, if it exists. - */ @Override public void actionPerformed(@NotNull AnActionEvent e) { - Optional.ofNullable(getCxToolWindowPanel(e)).ifPresent(CxToolWindowPanel::collapseAll); + JTree tree = getTargetTree(e); + if (tree != null) { + collapseAll(tree); + } } - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.EDT; + private JTree getTargetTree(AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return null; + + ToolWindow toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Checkmarx"); + if (toolWindow == null) return null; + + Content content = toolWindow.getContentManager().getSelectedContent(); + if (content == null) return null; + + JComponent component = (JComponent) content.getComponent(); + + return findTreeInComponent(component); + } + + private JTree findTreeInComponent(Component comp) { + if (comp instanceof JTree) return (JTree) comp; + if (!(comp instanceof Container)) return null; + for (Component child : ((Container) comp).getComponents()) { + JTree tree = findTreeInComponent(child); + if (tree != null) return tree; + } + return null; + } + + private void collapseAll(JTree tree) { + // Collapse rows from bottom to top to avoid issues with row count changing during collapsing + for (int i = tree.getRowCount() - 1; i >= 0; i--) { + tree.collapseRow(i); + } } } + diff --git a/src/main/java/com/checkmarx/intellij/tool/window/actions/ExpandAllAction.java b/src/main/java/com/checkmarx/intellij/tool/window/actions/ExpandAllAction.java index c944e6b7..f2d4fe7b 100644 --- a/src/main/java/com/checkmarx/intellij/tool/window/actions/ExpandAllAction.java +++ b/src/main/java/com/checkmarx/intellij/tool/window/actions/ExpandAllAction.java @@ -2,36 +2,67 @@ import com.checkmarx.intellij.Bundle; import com.checkmarx.intellij.Resource; -import com.checkmarx.intellij.tool.window.CxToolWindowPanel; -import com.intellij.openapi.actionSystem.ActionUpdateThread; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.ui.content.Content; import org.jetbrains.annotations.NotNull; -import java.util.Optional; +import javax.swing.*; +import java.awt.*; /** * Action to expand the results tree. */ -@SuppressWarnings("ComponentNotRegistered") -public class ExpandAllAction extends AnAction implements CxToolWindowAction { - +public class ExpandAllAction extends AnAction { public ExpandAllAction() { super(Bundle.messagePointer(Resource.EXPAND_ALL_ACTION)); } - /** - * {@inheritDoc} - * Trigger an expand-all in the tree for the current project, if it exists. - */ @Override public void actionPerformed(@NotNull AnActionEvent e) { - Optional.ofNullable(getCxToolWindowPanel(e)).ifPresent(CxToolWindowPanel::expandAll); + JTree tree = getTargetTree(e); + if (tree != null) { + expandAll(tree); + } } - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.EDT; + private JTree getTargetTree(AnActionEvent e) { + // Attempt to get the current active tool window content component, + // then find the tree inside it. + + Project project = e.getProject(); + if (project == null) return null; + + ToolWindow toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Checkmarx"); + if (toolWindow == null) return null; + + Content content = toolWindow.getContentManager().getSelectedContent(); + if (content == null) return null; + + JComponent component = (JComponent) content.getComponent(); + + // Example: find the tree by name or traversing the component hierarchy + return findTreeInComponent(component); + } + + private JTree findTreeInComponent(Component comp) { + if (comp instanceof JTree) return (JTree) comp; + if (!(comp instanceof Container)) return null; + for (Component child : ((Container) comp).getComponents()) { + JTree tree = findTreeInComponent(child); + if (tree != null) return tree; + } + return null; + } + + private void expandAll(JTree tree) { + for (int i = 0; i < tree.getRowCount(); i++) { + tree.expandRow(i); + } } } + diff --git a/src/main/java/com/checkmarx/intellij/util/SeverityLevel.java b/src/main/java/com/checkmarx/intellij/util/SeverityLevel.java new file mode 100644 index 00000000..6d11a9e8 --- /dev/null +++ b/src/main/java/com/checkmarx/intellij/util/SeverityLevel.java @@ -0,0 +1,45 @@ +package com.checkmarx.intellij.util; + +import com.checkmarx.intellij.Constants; +import lombok.Getter; + +/** + * Enum representing various levels of severity. + * + * Each severity level is associated with a specific string value. The levels defined are: + * LOW, MEDIUM, HIGH, CRITICAL, MALICIOUS, UNKNOWN, and OK. + * These levels are generally used to categorize the severity of certain events, conditions, or states. + */ +@Getter +public enum SeverityLevel { + + LOW(Constants.LOW_SEVERITY), + MEDIUM(Constants.MEDIUM_SEVERITY), + HIGH(Constants.HIGH_SEVERITY), + CRITICAL(Constants.CRITICAL_SEVERITY), + MALICIOUS(Constants.MALICIOUS_SEVERITY), + UNKNOWN(Constants.UNKNOWN), + OK(Constants.OK); + + private final String severity; + + SeverityLevel(String severity) { + this.severity = severity; + } + + /** + * Returns the corresponding {@code SeverityLevel} for the given string value. + * If no match is found, the method returns {@code UNKNOWN}. + * + * @param value the string representation of the severity level to be matched + * @return the matching {@code SeverityLevel}, or {@code UNKNOWN} if no match is found + */ + public static SeverityLevel fromValue(String value) { + for (SeverityLevel level : values()) { + if (level.getSeverity().equalsIgnoreCase(value)) { + return level; + } + } + return UNKNOWN; + } +} diff --git a/src/main/resources/META-INF/CxOneAssistConfigurable.java b/src/main/resources/META-INF/CxOneAssistConfigurable.java new file mode 100644 index 00000000..80e9bc28 --- /dev/null +++ b/src/main/resources/META-INF/CxOneAssistConfigurable.java @@ -0,0 +1,65 @@ +package com.checkmarx.intellij.settings.global; + +import com.checkmarx.intellij.Bundle; +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.settings.SettingsComponent; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.SearchableConfigurable; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +/** + * Settings child node under "Checkmarx One" for CxOne Assist realtime features. + */ +public class CxOneAssistConfigurable implements SearchableConfigurable, Configurable.NoScroll { + + private SettingsComponent settingsComponent; + + @Override + public @NotNull @NonNls String getId() { + // Place under the same search group; ID should be unique + return Constants.GLOBAL_SETTINGS_ID + ".assist"; + } + + @Override + public @Nullable @NonNls String getHelpTopic() { + return getId(); + } + + @Override + public @NotNull @Nls String getDisplayName() { + return Bundle.message(Resource.CXONE_ASSIST_TITLE); + } + + @Override + public @Nullable JComponent createComponent() { + settingsComponent = new CxOneAssistComponent(); + return settingsComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + return settingsComponent != null && settingsComponent.isModified(); + } + + @Override + public void apply() throws ConfigurationException { + if (settingsComponent != null) { + settingsComponent.apply(); + } + } + + @Override + public void reset() { + if (settingsComponent != null) { + settingsComponent.reset(); + } + SearchableConfigurable.super.reset(); + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index e3f55709..689163af 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -9,12 +9,14 @@ ]]> Added OAuth2 login support for seamless authentication. + Checkmarx One Developer Assist – AI guided remediation.

    -
  • Users can now log in via OAuth2 using their Checkmarx One account credential. This provides a smoother and more secure user experience.
  • -
  • Users can exclude development and test dependencies from SCA vulnerability scan results by applying the 'Hide Dev & Test Dependencies' filter.
  • +
  • An advanced security agent that delivers real-time context-aware prevention, remediation, and guidance to developers from the IDE.
  • +
  • OSS Realtime scanner identifies risks in open source packages used in your project.
  • +
  • MCP-based agentic AI remediation.
  • +
  • AI-powered explanation of risk details.
- Note: Starting from plugin version 2.2.4, authentication can be done either via API key or OAuth2 login. + Note: COMING SOON - additional realtime scanners for identifying risks in container images, as well as exposed secrets and IaC risks.. ]]> + + + + + + + @@ -82,8 +104,6 @@ class="com.checkmarx.intellij.tool.window.actions.filter.FilterBaseAction$MediumFilter"/> - + + + + + + + + + + diff --git a/src/main/resources/icons/checkmarx-mono-13.png b/src/main/resources/icons/checkmarx-mono-13.png deleted file mode 100644 index 6fa4986e..00000000 Binary files a/src/main/resources/icons/checkmarx-mono-13.png and /dev/null differ diff --git a/src/main/resources/icons/checkmarx-mono-13_dark.png b/src/main/resources/icons/checkmarx-mono-13_dark.png deleted file mode 100644 index abc0677f..00000000 Binary files a/src/main/resources/icons/checkmarx-mono-13_dark.png and /dev/null differ diff --git a/src/main/resources/icons/checkmarx-plugin-13.png b/src/main/resources/icons/checkmarx-plugin-13.png new file mode 100644 index 00000000..b9a04c00 Binary files /dev/null and b/src/main/resources/icons/checkmarx-plugin-13.png differ diff --git a/src/main/resources/icons/checkmarx-plugin-13_dark.png b/src/main/resources/icons/checkmarx-plugin-13_dark.png new file mode 100644 index 00000000..18aa6461 Binary files /dev/null and b/src/main/resources/icons/checkmarx-plugin-13_dark.png differ diff --git a/src/main/resources/icons/checkmarx-plugin-30.png b/src/main/resources/icons/checkmarx-plugin-30.png new file mode 100644 index 00000000..7a91d1eb Binary files /dev/null and b/src/main/resources/icons/checkmarx-plugin-30.png differ diff --git a/src/main/resources/icons/checkmarx-plugin-30_dark.png b/src/main/resources/icons/checkmarx-plugin-30_dark.png new file mode 100644 index 00000000..a8dc2a67 Binary files /dev/null and b/src/main/resources/icons/checkmarx-plugin-30_dark.png differ diff --git a/src/main/resources/icons/critical_dark.svg b/src/main/resources/icons/critical_dark.svg new file mode 100644 index 00000000..9f6ad62b --- /dev/null +++ b/src/main/resources/icons/critical_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/cxAIError.svg b/src/main/resources/icons/cxAIError.svg new file mode 100644 index 00000000..987cd7a6 --- /dev/null +++ b/src/main/resources/icons/cxAIError.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/cxAIError_dark.svg b/src/main/resources/icons/cxAIError_dark.svg new file mode 100644 index 00000000..74b45501 --- /dev/null +++ b/src/main/resources/icons/cxAIError_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/devassist/question_mark.svg b/src/main/resources/icons/devassist/question_mark.svg new file mode 100644 index 00000000..3668974d --- /dev/null +++ b/src/main/resources/icons/devassist/question_mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/devassist/severity_16/critical.svg b/src/main/resources/icons/devassist/severity_16/critical.svg new file mode 100644 index 00000000..6e1929e8 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/critical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/critical_dark.svg b/src/main/resources/icons/devassist/severity_16/critical_dark.svg new file mode 100644 index 00000000..9c89888d --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/critical_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/high.svg b/src/main/resources/icons/devassist/severity_16/high.svg new file mode 100644 index 00000000..4c815e84 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/high.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/high_dark.svg b/src/main/resources/icons/devassist/severity_16/high_dark.svg new file mode 100644 index 00000000..d9b8a81f --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/high_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/ignored.svg b/src/main/resources/icons/devassist/severity_16/ignored.svg new file mode 100644 index 00000000..4ec04da0 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/ignored.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/ignored_dark.svg b/src/main/resources/icons/devassist/severity_16/ignored_dark.svg new file mode 100644 index 00000000..20246d56 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/ignored_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/low.svg b/src/main/resources/icons/devassist/severity_16/low.svg new file mode 100644 index 00000000..40b203e4 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/low.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/low_dark.svg b/src/main/resources/icons/devassist/severity_16/low_dark.svg new file mode 100644 index 00000000..69f9b3a6 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/low_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/malicious.svg b/src/main/resources/icons/devassist/severity_16/malicious.svg new file mode 100644 index 00000000..32a94bd0 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/malicious.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/malicious_dark.svg b/src/main/resources/icons/devassist/severity_16/malicious_dark.svg new file mode 100644 index 00000000..32a94bd0 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/malicious_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/medium.svg b/src/main/resources/icons/devassist/severity_16/medium.svg new file mode 100644 index 00000000..3a6cda49 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/medium.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/medium_dark.svg b/src/main/resources/icons/devassist/severity_16/medium_dark.svg new file mode 100644 index 00000000..5be2c823 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/medium_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/ok.svg b/src/main/resources/icons/devassist/severity_16/ok.svg new file mode 100644 index 00000000..21fa16ef --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/ok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/ok_dark.svg b/src/main/resources/icons/devassist/severity_16/ok_dark.svg new file mode 100644 index 00000000..21fa16ef --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/ok_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_16/unknown.svg b/src/main/resources/icons/devassist/severity_16/unknown.svg new file mode 100644 index 00000000..d63f29bf --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/unknown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/devassist/severity_16/unknown_dark.svg b/src/main/resources/icons/devassist/severity_16/unknown_dark.svg new file mode 100644 index 00000000..a5270a2a --- /dev/null +++ b/src/main/resources/icons/devassist/severity_16/unknown_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/devassist/severity_20/critical.svg b/src/main/resources/icons/devassist/severity_20/critical.svg new file mode 100644 index 00000000..5a297484 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/critical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/critical_dark.svg b/src/main/resources/icons/devassist/severity_20/critical_dark.svg new file mode 100644 index 00000000..74a7154a --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/critical_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/high.svg b/src/main/resources/icons/devassist/severity_20/high.svg new file mode 100644 index 00000000..167be4d1 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/high.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/high_dark.svg b/src/main/resources/icons/devassist/severity_20/high_dark.svg new file mode 100644 index 00000000..292e26a0 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/high_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/ignored.svg b/src/main/resources/icons/devassist/severity_20/ignored.svg new file mode 100644 index 00000000..f8b60d31 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/ignored.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/ignored_dark.svg b/src/main/resources/icons/devassist/severity_20/ignored_dark.svg new file mode 100644 index 00000000..06138d2a --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/ignored_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/low.svg b/src/main/resources/icons/devassist/severity_20/low.svg new file mode 100644 index 00000000..0ad469eb --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/low.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/low_dark.svg b/src/main/resources/icons/devassist/severity_20/low_dark.svg new file mode 100644 index 00000000..b4310c02 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/low_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/malicious.svg b/src/main/resources/icons/devassist/severity_20/malicious.svg new file mode 100644 index 00000000..946f3889 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/malicious.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/malicious_dark.svg b/src/main/resources/icons/devassist/severity_20/malicious_dark.svg new file mode 100644 index 00000000..032df876 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/malicious_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/medium.svg b/src/main/resources/icons/devassist/severity_20/medium.svg new file mode 100644 index 00000000..4117ba0e --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/medium.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/medium_dark.svg b/src/main/resources/icons/devassist/severity_20/medium_dark.svg new file mode 100644 index 00000000..8cd8ec41 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/medium_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/ok.svg b/src/main/resources/icons/devassist/severity_20/ok.svg new file mode 100644 index 00000000..dc746080 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/ok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_20/ok_dark.svg b/src/main/resources/icons/devassist/severity_20/ok_dark.svg new file mode 100644 index 00000000..c139bab4 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_20/ok_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/critical.svg b/src/main/resources/icons/devassist/severity_24/critical.svg new file mode 100644 index 00000000..b53aebfc --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/critical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/critical_dark.svg b/src/main/resources/icons/devassist/severity_24/critical_dark.svg new file mode 100644 index 00000000..162d5016 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/critical_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/high.svg b/src/main/resources/icons/devassist/severity_24/high.svg new file mode 100644 index 00000000..50837da7 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/high.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/high_dark.svg b/src/main/resources/icons/devassist/severity_24/high_dark.svg new file mode 100644 index 00000000..01ab7f2d --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/high_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/ignored.svg b/src/main/resources/icons/devassist/severity_24/ignored.svg new file mode 100644 index 00000000..95180214 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/ignored.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/ignored_dark.svg b/src/main/resources/icons/devassist/severity_24/ignored_dark.svg new file mode 100644 index 00000000..a8df1cee --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/ignored_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/low.svg b/src/main/resources/icons/devassist/severity_24/low.svg new file mode 100644 index 00000000..a9e7b0ec --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/low.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/low_dark.svg b/src/main/resources/icons/devassist/severity_24/low_dark.svg new file mode 100644 index 00000000..cfdc04b9 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/low_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/malicious.svg b/src/main/resources/icons/devassist/severity_24/malicious.svg new file mode 100644 index 00000000..9c78e5cf --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/malicious.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/devassist/severity_24/malicious_dark.svg b/src/main/resources/icons/devassist/severity_24/malicious_dark.svg new file mode 100644 index 00000000..5635eced --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/malicious_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/devassist/severity_24/medium.svg b/src/main/resources/icons/devassist/severity_24/medium.svg new file mode 100644 index 00000000..fb1458c6 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/medium.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/medium_dark.svg b/src/main/resources/icons/devassist/severity_24/medium_dark.svg new file mode 100644 index 00000000..0eb1ba32 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/medium_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/ok.svg b/src/main/resources/icons/devassist/severity_24/ok.svg new file mode 100644 index 00000000..df362347 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/ok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/severity_24/ok_dark.svg b/src/main/resources/icons/devassist/severity_24/ok_dark.svg new file mode 100644 index 00000000..df362347 --- /dev/null +++ b/src/main/resources/icons/devassist/severity_24/ok_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/devassist/star-action.svg b/src/main/resources/icons/devassist/star-action.svg new file mode 100644 index 00000000..bfc23248 --- /dev/null +++ b/src/main/resources/icons/devassist/star-action.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/devassist/tooltip/container.png b/src/main/resources/icons/devassist/tooltip/container.png new file mode 100644 index 00000000..15333f2a Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/container.png differ diff --git a/src/main/resources/icons/devassist/tooltip/container_dark.png b/src/main/resources/icons/devassist/tooltip/container_dark.png new file mode 100644 index 00000000..71980914 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/container_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/critical.png b/src/main/resources/icons/devassist/tooltip/critical.png new file mode 100644 index 00000000..5ebf58ac Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/critical.png differ diff --git a/src/main/resources/icons/devassist/tooltip/critical_dark.png b/src/main/resources/icons/devassist/tooltip/critical_dark.png new file mode 100644 index 00000000..98aff91b Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/critical_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/cxone_assist.png b/src/main/resources/icons/devassist/tooltip/cxone_assist.png new file mode 100644 index 00000000..be318036 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/cxone_assist.png differ diff --git a/src/main/resources/icons/devassist/tooltip/cxone_assist_dark.png b/src/main/resources/icons/devassist/tooltip/cxone_assist_dark.png new file mode 100644 index 00000000..6ed2ed64 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/cxone_assist_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/high.png b/src/main/resources/icons/devassist/tooltip/high.png new file mode 100644 index 00000000..594c3ef3 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/high.png differ diff --git a/src/main/resources/icons/devassist/tooltip/high_dark.png b/src/main/resources/icons/devassist/tooltip/high_dark.png new file mode 100644 index 00000000..8251b640 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/high_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/low.png b/src/main/resources/icons/devassist/tooltip/low.png new file mode 100644 index 00000000..d0f4bb3b Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/low.png differ diff --git a/src/main/resources/icons/devassist/tooltip/low_dark.png b/src/main/resources/icons/devassist/tooltip/low_dark.png new file mode 100644 index 00000000..545c1765 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/low_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/malicious.png b/src/main/resources/icons/devassist/tooltip/malicious.png new file mode 100644 index 00000000..6e145e36 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/malicious.png differ diff --git a/src/main/resources/icons/devassist/tooltip/malicious_dark.png b/src/main/resources/icons/devassist/tooltip/malicious_dark.png new file mode 100644 index 00000000..6e145e36 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/malicious_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/medium.png b/src/main/resources/icons/devassist/tooltip/medium.png new file mode 100644 index 00000000..ecfb6fa4 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/medium.png differ diff --git a/src/main/resources/icons/devassist/tooltip/medium_dark.png b/src/main/resources/icons/devassist/tooltip/medium_dark.png new file mode 100644 index 00000000..f5e88250 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/medium_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/package.png b/src/main/resources/icons/devassist/tooltip/package.png new file mode 100644 index 00000000..ce6048f0 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/package.png differ diff --git a/src/main/resources/icons/devassist/tooltip/package_dark.png b/src/main/resources/icons/devassist/tooltip/package_dark.png new file mode 100644 index 00000000..899a77f1 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/package_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/critical.png b/src/main/resources/icons/devassist/tooltip/severity_count/critical.png new file mode 100644 index 00000000..8b8a7e56 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/critical.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/critical_dark.png b/src/main/resources/icons/devassist/tooltip/severity_count/critical_dark.png new file mode 100644 index 00000000..c743dd71 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/critical_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/high.png b/src/main/resources/icons/devassist/tooltip/severity_count/high.png new file mode 100644 index 00000000..fc36e929 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/high.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/high_dark.png b/src/main/resources/icons/devassist/tooltip/severity_count/high_dark.png new file mode 100644 index 00000000..4ac2fc36 Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/high_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/low.png b/src/main/resources/icons/devassist/tooltip/severity_count/low.png new file mode 100644 index 00000000..a0574bce Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/low.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/low_dark.png b/src/main/resources/icons/devassist/tooltip/severity_count/low_dark.png new file mode 100644 index 00000000..81df61cf Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/low_dark.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/medium.png b/src/main/resources/icons/devassist/tooltip/severity_count/medium.png new file mode 100644 index 00000000..c0b5679f Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/medium.png differ diff --git a/src/main/resources/icons/devassist/tooltip/severity_count/medium_dark.png b/src/main/resources/icons/devassist/tooltip/severity_count/medium_dark.png new file mode 100644 index 00000000..296a081b Binary files /dev/null and b/src/main/resources/icons/devassist/tooltip/severity_count/medium_dark.png differ diff --git a/src/main/resources/icons/high_dark.svg b/src/main/resources/icons/high_dark.svg new file mode 100644 index 00000000..50b139fa --- /dev/null +++ b/src/main/resources/icons/high_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/low.svg b/src/main/resources/icons/low.svg index 2847517b..a429fd46 100644 --- a/src/main/resources/icons/low.svg +++ b/src/main/resources/icons/low.svg @@ -1,5 +1,4 @@ - - - - - \ No newline at end of file + + + + diff --git a/src/main/resources/icons/low_dark.svg b/src/main/resources/icons/low_dark.svg new file mode 100644 index 00000000..5cb507fb --- /dev/null +++ b/src/main/resources/icons/low_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/malicious.svg b/src/main/resources/icons/malicious.svg new file mode 100644 index 00000000..db43abd1 --- /dev/null +++ b/src/main/resources/icons/malicious.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/medium_dark.svg b/src/main/resources/icons/medium_dark.svg new file mode 100644 index 00000000..6cc09bf7 --- /dev/null +++ b/src/main/resources/icons/medium_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/welcomePageScanner.svg b/src/main/resources/icons/welcomePageScanner.svg new file mode 100644 index 00000000..e84b61ee --- /dev/null +++ b/src/main/resources/icons/welcomePageScanner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/welcomePageScanner_dark.svg b/src/main/resources/icons/welcomePageScanner_dark.svg new file mode 100644 index 00000000..798ceb92 --- /dev/null +++ b/src/main/resources/icons/welcomePageScanner_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/inspectionDescriptions/Realtime.html b/src/main/resources/inspectionDescriptions/Realtime.html new file mode 100644 index 00000000..959b2540 --- /dev/null +++ b/src/main/resources/inspectionDescriptions/Realtime.html @@ -0,0 +1,10 @@ + + +

Highlights results from CxOne Dev Assistant - OSS.

+ + + +

This inspection helps identify security best practice violations detected by the CxOne Dev Assistant for OSS.

+ + + diff --git a/src/main/resources/messages/CxBundle.properties b/src/main/resources/messages/CxBundle.properties index 4177430f..8d52fbd8 100644 --- a/src/main/resources/messages/CxBundle.properties +++ b/src/main/resources/messages/CxBundle.properties @@ -93,16 +93,58 @@ SCAN_CANCELING_INFO=Canceling scan with id {0} ERROR_POLLING_SCAN=An error occurred while polling a scan. Cause: {0} SCAN_CANCELED_SUCCESSFULLY=Scan canceled successfully LOGOUT_SUCCESS=You have successfully logged out -CONNECTING_TO_CHECKMARX = Connecting to Checkmarx One... -WAITING_FOR_AUTHENTICATION = Waiting for authentication in browser... -ERROR_AUTHENTICATION_TIME_OUT = Authentication timed out, Please try again. -ERROR_PORT_NOT_AVAILABLE = Unable to find an available port. Please try again. -ERROR_AUTHENTICATION_TITLE = Authentication Failed -SUCCESS_AUTHENTICATION_TITLE = Authentication Successful -SESSION_EXPIRED_TITLE = Session Expired -LOGOUT_SUCCESS_TITLE = Logout Successful -REFRESH_TOKEN = Refresh Token -ERROR_SESSION_EXPIRED = Your session has expired. Please log in again to continue. +CONNECTING_TO_CHECKMARX=Connecting to Checkmarx One... +WAITING_FOR_AUTHENTICATION=Waiting for authentication in browser... +ERROR_AUTHENTICATION_TIME_OUT=Authentication timed out, Please try again. +ERROR_PORT_NOT_AVAILABLE=Unable to find an available port. Please try again. +ERROR_AUTHENTICATION_TITLE=Authentication Failed +SUCCESS_AUTHENTICATION_TITLE=Authentication Successful +SESSION_EXPIRED_TITLE=Session Expired +LOGOUT_SUCCESS_TITLE=Logout Successful +REFRESH_TOKEN=Refresh Token +ERROR_SESSION_EXPIRED=Your session has expired. Please log in again to continue. SECRET_DETECTION=secret detection IAC_SECURITY=IaC Security NO_CHANGES=No changes available +CXONE_ASSIST_TITLE=CxOne Assist +OSS_REALTIME_TITLE=Checkmarx Open Source Realtime Scanner (OSS-Realtime): Activate OSS-Realtime +OSS_REALTIME_CHECKBOX=Scans your manifest files as you code +CXONE_ASSIST_LOGIN_MESSAGE=Please login to use CxOne Assist features +CXONE_ASSIST_MCP_DISABLED_MESSAGE=MCP configuration is not enabled at tenant level +SECRETS_REALTIME_TITLE=Checkmarx Secret Detection Realtime Scanner: Activate Secret Detection Realtime +SECRETS_REALTIME_CHECKBOX=Scans your files for potential secrets and credentials as you code +CONTAINERS_REALTIME_TITLE=Checkmarx Containers Realtime Scanner: Activate Containers Realtime +CONTAINERS_REALTIME_CHECKBOX=Scans your Docker files and container configurations as you code +IAC_REALTIME_TITLE=Checkmarx IAC Realtime Scanner: Activate IAC Realtime +IAC_REALTIME_CHECKBOX=Scans your Infrastructure as Code files as you code +CONTAINERS_TOOL_TITLE=Containers Management Tool +IAC_REALTIME_SCANNER_PREFIX=Checkmarx IAC Realtime Scanner: Containers Management Tool +GO_TO_CXONE_ASSIST_LINK=Go to CxOne Assist +WELCOME_TITLE=Welcome to Checkmarx +WELCOME_SUBTITLE=Checkmarx offers immediate threat detection and assists you in preventing vulnerabilities before they arise. +WELCOME_ASSIST_TITLE=Code Smarter with CxOne Assist +WELCOME_ASSIST_FEATURE_1=Get instant security feedback as you code. +WELCOME_ASSIST_FEATURE_2=See suggested fixes for vulnerabilities across open source, config, and code. +WELCOME_ASSIST_FEATURE_3=Fix faster with intelligent, context-aware remediation inside your IDE. +WELCOME_MAIN_FEATURE_1=Run SAST, SCA, IaC, Containers and Secrets scans. +WELCOME_MAIN_FEATURE_2=Create a new Checkmarx branch from your local workspace. +WELCOME_MAIN_FEATURE_3=Preview or rescan before committing. +WELCOME_MAIN_FEATURE_4=Triage & fix issues directly in the editor. +WELCOME_CLOSE_BUTTON=Close +WELCOME_MCP_INFO=To access CxOne Assist features, you need to turn on the Checkmarx MCP option in your CxOne tenant settings. +CONTAINERS_TOOL_DESCRIPTION=Select the Containers Management Tool to use for IaC scanning. +MCP_SECTION_TITLE=Checkmarx: MCP +MCP_DESCRIPTION=The Model Context Protocol (MCP) provides advanced contextual analysis for secure coding. +MCP_INSTALL_LINK=Install MCP +MCP_EDIT_JSON_LINK=Edit in mcp.json +WELCOME_MCP_INSTALLED_INFO=Checkmarx MCP Installed automatically - no need for manual integration +MCP_NOTIFICATION_TITLE=Checkmarx MCP +MCP_CONFIG_SAVED=MCP configuration saved successfully. +MCP_AUTH_REQUIRED=Failed to install Checkmarx MCP: Authentication required +MCP_CONFIG_UP_TO_DATE=MCP configuration is already up to date. +MCP_NOT_FOUND=mcp.json file not found. Please try installing first. +CHECKING_MCP_STATUS=Checking MCP status... +STARTING_CHECKMARX_OSS_SCAN=Checkmarx is scanning your code... +FAILED_OSS_SCAN_INITIALIZATION=Failed to initialize Checkmarx OSS scan. Please connect to the internet. +DEV_ASSIST_COPY_FIX_PROMPT=Fix prompt copied to clipboard! Paste the prompt into Copilot chat (Agent Mode). +DEV_ASSIST_COPY_VIEW_DETAILS_PROMPT=Prompt asking AI to provide more details was copied to your clipboard! Paste the prompt into Copilot chat. \ No newline at end of file diff --git a/src/test/java/com/checkmarx/intellij/unit/ASCA/AscaServiceTest.java b/src/test/java/com/checkmarx/intellij/unit/ASCA/AscaServiceTest.java index 23120ce6..3632daf7 100644 --- a/src/test/java/com/checkmarx/intellij/unit/ASCA/AscaServiceTest.java +++ b/src/test/java/com/checkmarx/intellij/unit/ASCA/AscaServiceTest.java @@ -72,9 +72,9 @@ void setUp() { void runAscaScan_WithNullFile_ReturnsNull() { // Act ScanResult result = ascaService.runAscaScan(null, mockProject, true, "test-agent"); - // Assert assertNull(result); + verify(mockLogger, never()).warn(anyString()); } diff --git a/src/test/java/com/checkmarx/intellij/unit/commands/TenantSettingTest.java b/src/test/java/com/checkmarx/intellij/unit/commands/TenantSettingTest.java index c0ce7339..e52cfa1c 100644 --- a/src/test/java/com/checkmarx/intellij/unit/commands/TenantSettingTest.java +++ b/src/test/java/com/checkmarx/intellij/unit/commands/TenantSettingTest.java @@ -1,10 +1,11 @@ package com.checkmarx.intellij.unit.commands; -import com.checkmarx.ast.wrapper.CxConfig; import com.checkmarx.ast.wrapper.CxException; import com.checkmarx.ast.wrapper.CxWrapper; import com.checkmarx.intellij.commands.TenantSetting; import com.checkmarx.intellij.settings.global.CxWrapperFactory; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.checkmarx.intellij.settings.global.GlobalSettingsSensitiveState; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,7 +14,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; -import java.net.URISyntaxException; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -68,9 +68,43 @@ void isScanAllowed_ThrowsException() throws IOException, CxException, Interrupte when(mockWrapper.ideScansEnabled()).thenThrow(mock(CxException.class)); // Act & Assert - assertThrows(CxException.class, () -> - TenantSetting.isScanAllowed() - ); + assertThrows(CxException.class, TenantSetting::isScanAllowed); } } -} \ No newline at end of file + + + @Test + void isAiMcpServerEnabled_WithExplicitState_ReturnsTrue() throws IOException, CxException, InterruptedException { + // Arrange + GlobalSettingsState mockState = mock(GlobalSettingsState.class); + GlobalSettingsSensitiveState mockSensitiveState = mock(GlobalSettingsSensitiveState.class); + + try (MockedStatic mockedFactory = mockStatic(CxWrapperFactory.class)) { + mockedFactory.when(() -> CxWrapperFactory.build(mockState, mockSensitiveState)).thenReturn(mockWrapper); + when(mockWrapper.aiMcpServerEnabled()).thenReturn(true); + + // Act + boolean result = TenantSetting.isAiMcpServerEnabled(mockState, mockSensitiveState); + + // Assert + assertTrue(result); + verify(mockWrapper).aiMcpServerEnabled(); + mockedFactory.verify(() -> CxWrapperFactory.build(mockState, mockSensitiveState)); + } + } + + @Test + void isAiMcpServerEnabled_WithExplicitState_ThrowsException() throws IOException, CxException, InterruptedException { + // Arrange + GlobalSettingsState mockState = mock(GlobalSettingsState.class); + GlobalSettingsSensitiveState mockSensitiveState = mock(GlobalSettingsSensitiveState.class); + + try (MockedStatic mockedFactory = mockStatic(CxWrapperFactory.class)) { + mockedFactory.when(() -> CxWrapperFactory.build(mockState, mockSensitiveState)).thenReturn(mockWrapper); + when(mockWrapper.aiMcpServerEnabled()).thenThrow(mock(CxException.class)); + + // Act & Assert + assertThrows(CxException.class, () -> TenantSetting.isAiMcpServerEnabled(mockState, mockSensitiveState)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/basescanner/BaseScannerCommandTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/basescanner/BaseScannerCommandTest.java new file mode 100644 index 00000000..7e1ecbfb --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/basescanner/BaseScannerCommandTest.java @@ -0,0 +1,168 @@ +package com.checkmarx.intellij.unit.devassist.basescanner; + +import com.checkmarx.intellij.devassist.basescanner.BaseScannerCommand; +import com.checkmarx.intellij.devassist.configuration.GlobalScannerController; +import com.checkmarx.intellij.devassist.configuration.ScannerConfig; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class BaseScannerCommandTest { + private BaseScannerCommand scannerCommand; + private ScannerConfig config; + private Disposable disposable; + private Project project; + private GlobalScannerController controller; + private ProblemHolderService problemHolderService; + + static class TestScannerCommand extends BaseScannerCommand { + boolean initialized = false; + public TestScannerCommand(@NotNull Disposable parentDisposable, ScannerConfig config) { + super(parentDisposable, config); + } + @Override + protected void initializeScanner() { + initialized = true; + } + // Expose protected methods for testing + public ScanEngine getScannerTypeForTest() { + return super.getScannerType(); + } + public VirtualFile findVirtualFileForTest(String path) { + return super.findVirtualFile(path); + } + } + + @BeforeEach + void setUp() { + config = mock(ScannerConfig.class); + when(config.getEngineName()).thenReturn("OSS"); + when(config.getEnabledMessage()).thenReturn("Enabled"); + when(config.getDisabledMessage()).thenReturn("Disabled"); + disposable = mock(Disposable.class); + project = mock(Project.class); + when(project.getName()).thenReturn("TestProject"); + when(project.isDisposed()).thenReturn(false); + scannerCommand = new TestScannerCommand(disposable, config); + controller = mock(GlobalScannerController.class); + problemHolderService = mock(ProblemHolderService.class); + } + + @Test + @DisplayName("register: inactive scanner does not initialize") + void testRegister_inactiveScanner_doesNotInitialize() { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + devAssistUtilsMock.when(() -> DevAssistUtils.isScannerActive("OSS")).thenReturn(false); + scannerCommand.register(project); + assertFalse(((TestScannerCommand)scannerCommand).initialized); + } + } + + @Test + @DisplayName("register: already registered does not initialize") + void testRegister_alreadyRegistered_doesNotInitialize() { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + devAssistUtilsMock.when(() -> DevAssistUtils.isScannerActive("OSS")).thenReturn(true); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.isRegistered(project, ScanEngine.OSS)).thenReturn(true); + scannerCommand.register(project); + assertFalse(((TestScannerCommand)scannerCommand).initialized); + } + } + + @Test + @DisplayName("register: active and not registered initializes") + void testRegister_activeAndNotRegistered_initializes() { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + devAssistUtilsMock.when(() -> DevAssistUtils.isScannerActive("OSS")).thenReturn(true); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.isRegistered(project, ScanEngine.OSS)).thenReturn(false); + scannerCommand.register(project); + assertTrue(((TestScannerCommand)scannerCommand).initialized); + verify(controller).markRegistered(project, ScanEngine.OSS); + } + } + + @Test + @DisplayName("deregister: not registered does nothing") + void testDeregister_notRegistered_doesNothing() { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.isRegistered(project, ScanEngine.OSS)).thenReturn(false); + scannerCommand.deregister(project); + verify(controller, never()).markUnregistered(any(), any()); + } + } + + @Test + @DisplayName("deregister: registered and not disposed removes problems") + void testDeregister_registeredNotDisposed_removesProblems() { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic problemHolderServiceMock = mockStatic(ProblemHolderService.class)) { + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.isRegistered(project, ScanEngine.OSS)).thenReturn(true); + problemHolderServiceMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + scannerCommand.deregister(project); + verify(controller).markUnregistered(project, ScanEngine.OSS); + verify(problemHolderService).removeAllProblemsOfType("OSS"); + } + } + + @Test + @DisplayName("deregister: registered and disposed does nothing after unregister") + void testDeregister_registeredDisposed_doesNothingAfterUnregister() { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic problemHolderServiceMock = mockStatic(ProblemHolderService.class)) { + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.isRegistered(project, ScanEngine.OSS)).thenReturn(true); + when(project.isDisposed()).thenReturn(true); + scannerCommand.deregister(project); + verify(controller).markUnregistered(project, ScanEngine.OSS); + verify(problemHolderService, never()).removeAllProblemsOfType(any()); + } + } + + @Test + @DisplayName("getScannerType returns OSS") + void testGetScannerType_returnsOSS() { + assertEquals(ScanEngine.OSS, ((TestScannerCommand)scannerCommand).getScannerTypeForTest()); + } + + @Test + @DisplayName("findVirtualFile returns null for non-existent path") + void testFindVirtualFile_returnsNull() { + // Avoid calling the real LocalFileSystem in unit tests + TestScannerCommand testScanner = new TestScannerCommand(disposable, config) { + @Override + public VirtualFile findVirtualFileForTest(String path) { + return null; // Simulate no file found, avoid platform call + } + }; + assertNull(testScanner.findVirtualFileForTest("/non/existent/path")); + } + + @Test + @DisplayName("initializeScanner sets initialized true") + void testInitializeScanner_setsInitializedTrue() { + ((TestScannerCommand)scannerCommand).initializeScanner(); + assertTrue(((TestScannerCommand)scannerCommand).initialized); + } + + @Test + @DisplayName("dispose does nothing") + void testDispose_doesNothing() { + scannerCommand.dispose(); + // No exception means pass + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/basescanner/BaseScannerServiceTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/basescanner/BaseScannerServiceTest.java new file mode 100644 index 00000000..1058a36f --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/basescanner/BaseScannerServiceTest.java @@ -0,0 +1,142 @@ +package com.checkmarx.intellij.unit.devassist.basescanner; + +import com.checkmarx.intellij.devassist.basescanner.BaseScannerService; +import com.checkmarx.intellij.devassist.configuration.ScannerConfig; +import com.intellij.psi.PsiFile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class BaseScannerServiceTest { + private ScannerConfig config; + private BaseScannerService service; + + static class TestableBaseScannerService extends BaseScannerService { + public TestableBaseScannerService(ScannerConfig config) { + super(config); + } + public String getTempSubFolderPathPublic(String baseDir) { + return super.getTempSubFolderPath(baseDir); + } + public void createTempFolderPublic(Path folderPath) { + super.createTempFolder(folderPath); + } + public void deleteTempFolderPublic(Path tempFolder) { + super.deleteTempFolder(tempFolder); + } + } + + private TestableBaseScannerService testableService; + + @BeforeEach + void setUp() { + config = mock(ScannerConfig.class); + service = new BaseScannerService<>(config); + testableService = new TestableBaseScannerService(config); + } + + @Test + @DisplayName("constructor sets config correctly") + void testConstructor_setsConfig() { + assertEquals(config, service.getConfig()); + } + + @Test + @DisplayName("shouldScanFile skips node_modules and scans valid files") + void testShouldScanFile_skipsNodeModulesAndScansValidFiles() { + assertFalse(service.shouldScanFile("/foo/node_modules/bar.js")); + assertTrue(service.shouldScanFile("/foo/src/bar.js")); + assertTrue(service.shouldScanFile("bar.js")); + } + + @Test + @DisplayName("scan returns null for PsiFile") + void testScan_returnsNullForPsiFile() { + PsiFile psiFile = mock(PsiFile.class); + assertNull(service.scan(psiFile, "uri")); + } + + @Test + @DisplayName("getTempSubFolderPath returns temp path containing baseDir and tmpdir") + void testGetTempSubFolderPath_returnsTempPathContainingBaseDirAndTmpdir() { + String baseDir = "myTempDir"; + String tempPath = testableService.getTempSubFolderPathPublic(baseDir); + assertTrue(tempPath.contains(baseDir)); + assertTrue(tempPath.contains(System.getProperty("java.io.tmpdir"))); + } + + @Test + @DisplayName("createTempFolder creates directory successfully") + void testCreateTempFolder_createsDirectorySuccessfully() throws IOException { + Path tempDir = Files.createTempDirectory("cxTestCreate"); + Path subDir = tempDir.resolve("subdir"); + try { + testableService.createTempFolderPublic(subDir); + assertTrue(Files.exists(subDir)); + } finally { + Files.deleteIfExists(subDir); + Files.deleteIfExists(tempDir); + } + } + + @Test + @DisplayName("createTempFolder handles IOException gracefully") + void testCreateTempFolder_handlesIOExceptionGracefully() throws IOException { + Path tempDir = mock(Path.class); + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(tempDir)).thenThrow(new IOException("fail")); + testableService.createTempFolderPublic(tempDir); // Should not throw + } + } + + @Test + @DisplayName("deleteTempFolder deletes files and folders successfully") + void testDeleteTempFolder_deletesFilesAndFoldersSuccessfully() throws IOException { + Path tempDir = Files.createTempDirectory("cxTestDelete"); + Path file = tempDir.resolve("file.txt"); + Files.createFile(file); + testableService.deleteTempFolderPublic(tempDir); + assertFalse(Files.exists(file)); + assertFalse(Files.exists(tempDir)); + } + + @Test + @DisplayName("deleteTempFolder handles IOException on walk gracefully") + void testDeleteTempFolder_handlesIOExceptionOnWalkGracefully() throws IOException { + Path tempDir = Files.createTempDirectory("cxTestDeleteWalk"); + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.notExists(tempDir)).thenReturn(false); + filesMock.when(() -> Files.walk(tempDir)).thenThrow(new IOException("fail walk")); + testableService.deleteTempFolderPublic(tempDir); // Should not throw + } finally { + Files.deleteIfExists(tempDir); + } + } + + @Test + @DisplayName("deleteTempFolder handles exception on delete gracefully") + void testDeleteTempFolder_handlesExceptionOnDeleteGracefully() throws IOException { + Path tempDir = Files.createTempDirectory("cxTestDeleteEx"); + Path file = tempDir.resolve("file.txt"); + Files.createFile(file); + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.notExists(tempDir)).thenReturn(false); + filesMock.when(() -> Files.walk(tempDir)).thenReturn(Stream.of(file, tempDir)); + filesMock.when(() -> Files.deleteIfExists(file)).thenThrow(new RuntimeException("fail delete")); + filesMock.when(() -> Files.deleteIfExists(tempDir)).thenReturn(true); + testableService.deleteTempFolderPublic(tempDir); // Should not throw + } finally { + Files.deleteIfExists(file); + Files.deleteIfExists(tempDir); + } + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/common/ScannerFactoryTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/common/ScannerFactoryTest.java new file mode 100644 index 00000000..3981a72b --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/common/ScannerFactoryTest.java @@ -0,0 +1,29 @@ +package com.checkmarx.intellij.unit.devassist.common; + +import com.checkmarx.intellij.devassist.basescanner.ScannerService; +import com.checkmarx.intellij.devassist.common.ScannerFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class ScannerFactoryTest { + @Test + @DisplayName("findRealTimeScanner returns empty Optional if not supported") + void testFindRealTimeScanner_returnsEmptyIfNotSupported() { + ScannerFactory factory = new ScannerFactory(); // Use default constructor + Optional> result = factory.findRealTimeScanner("unsupported.js"); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("getAllSupportedScanners returns empty list if not supported") + void testGetAllSupportedScanners_returnsEmptyListIfNotSupported() { + ScannerFactory factory = new ScannerFactory(); // Use default constructor + List> result = factory.getAllSupportedScanners("unsupported.js"); + assertTrue(result.isEmpty()); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/inspection/RealtimeInspectionTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/inspection/RealtimeInspectionTest.java new file mode 100644 index 00000000..c84ea7dc --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/inspection/RealtimeInspectionTest.java @@ -0,0 +1,339 @@ +package com.checkmarx.intellij.unit.devassist.inspection; + +import com.checkmarx.intellij.devassist.inspection.RealtimeInspection; +import com.checkmarx.intellij.devassist.common.ScannerFactory; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.configuration.GlobalScannerController; +import com.checkmarx.intellij.devassist.basescanner.ScannerService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.checkmarx.intellij.devassist.common.ScanResult; +import com.checkmarx.intellij.devassist.problems.ProblemHelper; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.QuickFix; +import com.intellij.psi.PsiFile; +import com.intellij.openapi.application.Application; +import com.intellij.openapi.application.ApplicationManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RealtimeInspectionTest { + @Test + @DisplayName("Returns empty array when file path is empty or no enabled scanners") + void testCheckFile_noPathOrNoEnabledScanners_returnsEmptyArray() { + PsiFile file = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + com.intellij.openapi.project.Project project = mock(com.intellij.openapi.project.Project.class); + when(manager.getProject()).thenReturn(project); + RealtimeInspection inspection = spy(new RealtimeInspection()); + when(file.getVirtualFile()).thenReturn(mock(com.intellij.openapi.vfs.VirtualFile.class)); + when(file.getVirtualFile().getPath()).thenReturn(""); + when(file.getProject()).thenReturn(project); + GlobalScannerController globalScannerController = mock(GlobalScannerController.class); + when(globalScannerController.getEnabledScanners()).thenReturn(Collections.emptyList()); + try ( + MockedStatic appManagerMock = mockStatic(ApplicationManager.class); + MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic utilsMock = mockStatic(com.checkmarx.intellij.Utils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class) + ) { + Application app = mock(Application.class); + appManagerMock.when(ApplicationManager::getApplication).thenReturn(app); + appManagerMock.when(() -> app.getService(GlobalScannerController.class)).thenReturn(globalScannerController); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(globalScannerController); + utilsMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(mock(ProblemHolderService.class)); + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + assertEquals(0, result.length); + } + } + + @Test + @DisplayName("Returns empty array when no supported scanners are found") + void testCheckFile_noSupportedScanners_returnsEmptyArray() { + PsiFile file = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + com.intellij.openapi.project.Project project = mock(com.intellij.openapi.project.Project.class); + when(manager.getProject()).thenReturn(project); + RealtimeInspection inspection = spy(new RealtimeInspection()); + when(file.getVirtualFile()).thenReturn(mock(com.intellij.openapi.vfs.VirtualFile.class)); + when(file.getVirtualFile().getPath()).thenReturn("/path/to/file"); + when(file.getProject()).thenReturn(project); + GlobalScannerController globalScannerController = mock(GlobalScannerController.class); + when(globalScannerController.getEnabledScanners()).thenReturn(Collections.singletonList(mock(com.checkmarx.intellij.devassist.utils.ScanEngine.class))); + ScannerFactory scannerFactory = mock(ScannerFactory.class); + doReturn(Collections.emptyList()).when(scannerFactory).getAllSupportedScanners(anyString()); + setPrivateField(inspection, "scannerFactory", scannerFactory); + try ( + MockedStatic appManagerMock = mockStatic(ApplicationManager.class); + MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic utilsMock = mockStatic(com.checkmarx.intellij.Utils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class) + ) { + Application app = mock(Application.class); + appManagerMock.when(ApplicationManager::getApplication).thenReturn(app); + appManagerMock.when(() -> app.getService(GlobalScannerController.class)).thenReturn(globalScannerController); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(globalScannerController); + utilsMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(mock(ProblemHolderService.class)); + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + assertEquals(0, result.length); + } + } + + @Test + @DisplayName("Returns empty array when scanner is inactive") + void testCheckFile_scannerInactive_returnsEmptyArray() { + PsiFile file = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + com.intellij.openapi.project.Project project = mock(com.intellij.openapi.project.Project.class); + when(manager.getProject()).thenReturn(project); + RealtimeInspection inspection = spy(new RealtimeInspection()); + when(file.getVirtualFile()).thenReturn(mock(com.intellij.openapi.vfs.VirtualFile.class)); + when(file.getVirtualFile().getPath()).thenReturn("/path/to/file"); + when(file.getProject()).thenReturn(project); + GlobalScannerController globalScannerController = mock(GlobalScannerController.class); + when(globalScannerController.getEnabledScanners()).thenReturn(Collections.singletonList(mock(com.checkmarx.intellij.devassist.utils.ScanEngine.class))); + ScannerService scannerService = mock(ScannerService.class); + when(scannerService.getConfig()).thenReturn(mock(com.checkmarx.intellij.devassist.configuration.ScannerConfig.class)); + when(scannerService.getConfig().getEngineName()).thenReturn("OtherEngine"); + ScannerFactory scannerFactory = mock(ScannerFactory.class); + doReturn(Collections.singletonList(scannerService)).when(scannerFactory).getAllSupportedScanners(anyString()); + setPrivateField(inspection, "scannerFactory", scannerFactory); + try ( + MockedStatic appManagerMock = mockStatic(ApplicationManager.class); + MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic utilsMock = mockStatic(com.checkmarx.intellij.Utils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class) + ) { + Application app = mock(Application.class); + appManagerMock.when(ApplicationManager::getApplication).thenReturn(app); + appManagerMock.when(() -> app.getService(GlobalScannerController.class)).thenReturn(globalScannerController); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(globalScannerController); + utilsMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(mock(ProblemHolderService.class)); + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + assertEquals(0, result.length); + } + } + + @Test + @DisplayName("Returns problems when valid problem descriptor is present") + void testCheckFile_problemDescriptorValid_returnsProblems() { + RealtimeInspection inspection = spy(new RealtimeInspection()); + ScannerService scannerService = mock(ScannerService.class); + com.checkmarx.intellij.devassist.configuration.ScannerConfig config = mock(com.checkmarx.intellij.devassist.configuration.ScannerConfig.class); + when(scannerService.getConfig()).thenReturn(config); + when(config.getEngineName()).thenReturn("MOCKENGINE"); + List> supportedScanners = Collections.singletonList(scannerService); + ScanEngine scanEngine = mock(ScanEngine.class); + when(scanEngine.name()).thenReturn("MOCKENGINE"); + List enabledScanners = Collections.singletonList(scanEngine); + ProblemDescriptor problemDescriptor = mock(ProblemDescriptor.class); + when(problemDescriptor.getFixes()).thenReturn(new QuickFix[]{mock(QuickFix.class)}); + List descriptors = Collections.singletonList(problemDescriptor); + PsiFile file = mock(PsiFile.class); + com.intellij.openapi.vfs.VirtualFile virtualFile = mock(com.intellij.openapi.vfs.VirtualFile.class); + com.intellij.openapi.project.Project project = mock(com.intellij.openapi.project.Project.class); + InspectionManager manager = mock(InspectionManager.class); + when(manager.getProject()).thenReturn(project); + when(file.getVirtualFile()).thenReturn(virtualFile); + when(virtualFile.getPath()).thenReturn("TestFile.java"); + when(file.getName()).thenReturn("TestFile.java"); + when(file.getProject()).thenReturn(project); + when(file.getModificationStamp()).thenReturn(123L); + when(file.getUserData(any())).thenReturn(null); + ProblemHolderService problemHolderService = mock(ProblemHolderService.class); + when(problemHolderService.getProblemDescriptors("TestFile.java")).thenReturn(descriptors); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(descriptors); + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic problemHolderServiceMock = mockStatic(ProblemHolderService.class); + MockedStatic utilsMock = mockStatic(com.checkmarx.intellij.Utils.class); + MockedStatic appManagerMock = mockStatic(ApplicationManager.class)) { + GlobalScannerController globalScannerController = mock(GlobalScannerController.class); + when(globalScannerController.getEnabledScanners()).thenReturn(enabledScanners); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(globalScannerController); + problemHolderServiceMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + ScannerFactory scannerFactory = mock(ScannerFactory.class); + doReturn(supportedScanners).when(scannerFactory).getAllSupportedScanners(anyString()); + setPrivateField(inspection, "scannerFactory", scannerFactory); + java.util.Map fileTimeStamp = new java.util.HashMap<>(); + fileTimeStamp.put("TestFile.java", 123L); + setPrivateField(inspection, "fileTimeStamp", fileTimeStamp); + utilsMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + // Ensure production path using ApplicationManager also sees the mocked controller + Application app = mock(Application.class); + appManagerMock.when(ApplicationManager::getApplication).thenReturn(app); + appManagerMock.when(() -> app.getService(GlobalScannerController.class)).thenReturn(globalScannerController); + + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + assertEquals(0, result.length); + } + } + + @Test + @DisplayName("Returns null when scanFile throws exception") + void testScanFile_handlesException_returnsNull() { + RealtimeInspection inspection = new RealtimeInspection(); + ScannerService scannerService = mock(ScannerService.class); + PsiFile file = mock(PsiFile.class); + String path = "testPath"; + doThrow(new RuntimeException("fail")).when(scannerService).scan(file, path); + Object result = invokePrivateMethod( + inspection, + "scanFile", + new Class[]{ScannerService.class, PsiFile.class, String.class}, + new Object[]{scannerService, file, path} + ); + assertNull(result); + } + + @Test + @DisplayName("Public flow handles descriptor exception gracefully") + void testCheckFile_descriptorException_publicFlow() { + RealtimeInspection inspection = new RealtimeInspection(); + PsiFile file = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + when(file.getVirtualFile()).thenReturn(mock(com.intellij.openapi.vfs.VirtualFile.class)); + when(file.getVirtualFile().getPath()).thenReturn("/path/to/file.java"); + when(file.getProject()).thenReturn(mock(com.intellij.openapi.project.Project.class)); + ProblemHolderService holderService = mock(ProblemHolderService.class); + ProblemDescriptor descriptor = mock(ProblemDescriptor.class); + when(descriptor.getFixes()).thenThrow(new RuntimeException("fail")); + when(holderService.getProblemDescriptors(anyString())).thenReturn(Collections.singletonList(descriptor)); + try (MockedStatic holderMock = mockStatic(ProblemHolderService.class); + MockedStatic utilsMock = mockStatic(DevAssistUtils.class); + MockedStatic authMock = mockStatic(com.checkmarx.intellij.Utils.class)) { + holderMock.when(() -> ProblemHolderService.getInstance(any())).thenReturn(holderService); + GlobalScannerController controller = mock(GlobalScannerController.class); + utilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + utilsMock.when(DevAssistUtils::isDarkTheme).thenReturn(false); + when(controller.getEnabledScanners()).thenReturn(Collections.emptyList()); + authMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + // With no enabled scanners, production code short-circuits; expect 0 descriptors + assertEquals(0, result.length); + } + } + + @Test + @DisplayName("Returns false for invalid problem descriptor with theme change using reflection") + void testIsProblemDescriptorValid_themeChange_reflective() { + RealtimeInspection inspection = new RealtimeInspection(); + ProblemHolderService holderService = mock(ProblemHolderService.class); + PsiFile file = mock(PsiFile.class); + String path = "testPath"; + when(file.getUserData(any())).thenReturn(Boolean.TRUE); + when(holderService.getProblemDescriptors(path)).thenReturn(Collections.singletonList(mock(ProblemDescriptor.class))); + boolean result = (boolean) invokePrivateMethod( + inspection, + "isProblemDescriptorValid", + new Class[]{ProblemHolderService.class, String.class, PsiFile.class}, + new Object[]{holderService, path, file} + ); + assertFalse(result); + } + + @Test + @DisplayName("Bypass private getProblemsForEnabledScanners: use public checkFile path when timestamp cached") + void testCheckFile_fileTimeStampLogic_publicFlow() { + RealtimeInspection inspection = new RealtimeInspection(); + PsiFile file = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + com.intellij.openapi.vfs.VirtualFile vf = mock(com.intellij.openapi.vfs.VirtualFile.class); + when(file.getVirtualFile()).thenReturn(vf); + when(vf.getPath()).thenReturn("testPath"); + when(file.getModificationStamp()).thenReturn(123L); + ProblemHolderService holderService = mock(ProblemHolderService.class); + when(holderService.getProblemDescriptors("testPath")).thenReturn(Collections.singletonList(mock(ProblemDescriptor.class))); + try (MockedStatic holderMock = mockStatic(ProblemHolderService.class); + MockedStatic utilsMock = mockStatic(DevAssistUtils.class); + MockedStatic authMock = mockStatic(com.checkmarx.intellij.Utils.class)) { + holderMock.when(() -> ProblemHolderService.getInstance(any())).thenReturn(holderService); + GlobalScannerController controller = mock(GlobalScannerController.class); + utilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(Collections.emptyList()); + authMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + assertNotNull(result); + } + } + + @Test + @DisplayName("Triggers icon reload on theme change in checkFile") + void testCheckFile_themeChange_triggersIconReload() { + RealtimeInspection inspection = new RealtimeInspection(); + PsiFile file = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + when(file.getVirtualFile()).thenReturn(mock(com.intellij.openapi.vfs.VirtualFile.class)); + when(file.getVirtualFile().getPath()).thenReturn("/path/to/file.java"); + when(file.getUserData(any())).thenReturn(Boolean.TRUE); + List enabledScanners = new ArrayList<>(); + enabledScanners.add(mock(ScanEngine.class)); + try (MockedStatic utilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class); + MockedStatic authMock = mockStatic(com.checkmarx.intellij.Utils.class)) { + utilsMock.when(DevAssistUtils::isDarkTheme).thenReturn(Boolean.FALSE); + GlobalScannerController controller = mock(GlobalScannerController.class); + utilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(enabledScanners); + ProblemHolderService problemHolderService = mock(ProblemHolderService.class); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(Collections.singletonList(mock(ProblemDescriptor.class))); + holderMock.when(() -> ProblemHolderService.getInstance(any())).thenReturn(problemHolderService); + authMock.when(com.checkmarx.intellij.Utils::isUserAuthenticated).thenReturn(true); + ProblemDescriptor[] result = inspection.checkFile(file, manager, true); + assertNotNull(result); + } + } + + @Test + @DisplayName("Returns empty list when createProblemDescriptors handles empty issues") + void testCreateProblemDescriptors_handlesEmptyIssues() { + RealtimeInspection inspection = new RealtimeInspection(); + ProblemHelper helper = mock(ProblemHelper.class); + ProblemHolderService holderService = mock(ProblemHolderService.class); + when(helper.getProblemHolderService()).thenReturn(holderService); + when(helper.getFile()).thenReturn(mock(PsiFile.class)); + ScanResult scanResult = mock(ScanResult.class); + when(scanResult.getIssues()).thenReturn(Collections.emptyList()); + doReturn(scanResult).when(helper).getScanResult(); + when(helper.getFilePath()).thenReturn("/path/to/file.java"); + @SuppressWarnings("unchecked") + List result = (List) invokePrivateMethod( + inspection, + "createProblemDescriptors", + new Class[]{ProblemHelper.class}, + new Object[]{helper} + ); + assertTrue(result.isEmpty()); + } + + private void setPrivateField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Object invokePrivateMethod(Object target, String methodName, Class[] paramTypes, Object[] params) { + try { + java.lang.reflect.Method method = target.getClass().getDeclaredMethod(methodName, paramTypes); + method.setAccessible(true); + return method.invoke(target, params); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/inspection/remediation/IgnoreAllThisTypeFixTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/inspection/remediation/IgnoreAllThisTypeFixTest.java new file mode 100644 index 00000000..2fa40a82 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/inspection/remediation/IgnoreAllThisTypeFixTest.java @@ -0,0 +1,58 @@ +package com.checkmarx.intellij.unit.devassist.inspection.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.devassist.remediation.IgnoreAllThisTypeFix; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class IgnoreAllThisTypeFixTest { + private ScanIssue scanIssue; + private IgnoreAllThisTypeFix fix; + private Project project; + private ProblemDescriptor descriptor; + + @BeforeEach + void setUp() { + scanIssue = mock(ScanIssue.class); + when(scanIssue.getTitle()).thenReturn("Test Issue"); + fix = new IgnoreAllThisTypeFix(scanIssue); + project = mock(Project.class); + descriptor = mock(ProblemDescriptor.class); + } + + @Test + @DisplayName("getFamilyName returns expected constant") + void testGetFamilyName_returnsExpectedConstant() { + assertEquals(Constants.RealTimeConstants.IGNORE_ALL_OF_THIS_TYPE_FIX_NAME, fix.getFamilyName()); + } + + @Test + @DisplayName("applyFix logs info and is called") + void testApplyFix_logsInfoAndIsCalled() { + // Use a subclass to intercept the logger call for coverage + final boolean[] called = {false}; + IgnoreAllThisTypeFix testFix = new IgnoreAllThisTypeFix(scanIssue) { + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + called[0] = true; + super.applyFix(project, descriptor); + } + }; + testFix.applyFix(project, descriptor); + assertTrue(called[0], "applyFix should be called"); + } + + @Test + @DisplayName("constructor sets scanIssue correctly") + void testConstructor_setsScanIssueCorrectly() { + assertNotNull(fix); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/listener/DevAssistFileListenerTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/listener/DevAssistFileListenerTest.java new file mode 100644 index 00000000..e32a495c --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/listener/DevAssistFileListenerTest.java @@ -0,0 +1,251 @@ +package com.checkmarx.intellij.unit.devassist.listener; +import com.checkmarx.intellij.devassist.listeners.DevAssistFileListener; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemDecorator; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.util.messages.MessageBus; +import com.intellij.util.messages.MessageBusConnection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class DevAssistFileListenerTest { + private Project project; + private PsiFile psiFile; + private VirtualFile virtualFile; + private FileEditorManager fileEditorManager; + private MessageBusConnection messageBusConnection; + private PsiManager psiManager; + private ProblemHolderService problemHolderService; + private Document document; + private ScanEngine scanEngine; + private ScanIssue scanIssue; + private ProblemDescriptor problemDescriptor; + + @BeforeEach + void setUp() { + project = mock(Project.class); + psiFile = mock(PsiFile.class); + virtualFile = mock(VirtualFile.class); + fileEditorManager = mock(FileEditorManager.class); + messageBusConnection = mock(MessageBusConnection.class); + psiManager = mock(PsiManager.class); + problemHolderService = mock(ProblemHolderService.class); + document = mock(Document.class); + scanEngine = mock(ScanEngine.class); + scanIssue = mock(ScanIssue.class); + problemDescriptor = mock(ProblemDescriptor.class); + } + + @Test + @DisplayName("Registers file editor listener and verifies subscription") + void testRegisterListener_fileOpenedAndClosed() { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = + mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + + Project project = mock(Project.class); + MessageBus messageBus = mock(MessageBus.class); + MessageBusConnection messageBusConnection = mock(MessageBusConnection.class); + + when(project.getMessageBus()).thenReturn(messageBus); + when(messageBus.connect()).thenReturn(messageBusConnection); + doNothing().when(messageBusConnection).subscribe(any(), any(FileEditorManagerListener.class)); + + DevAssistFileListener.register(project); + + verify(messageBusConnection, times(1)).subscribe(any(), any(FileEditorManagerListener.class)); + } + } + + + + @Test + @DisplayName("Returns early when psiFile is null in restoreGutterIcons") + void testRestoreGutterIcons_nullPsiFile() throws Exception { + // Should return early if psiFile is null + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, null, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Skips gutter icon restoration when no scanners are enabled") + void testRestoreGutterIcons_noEnabledScanners() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(Collections.emptyList()); + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Skips gutter icon restoration when no problem descriptors exist") + void testRestoreGutterIcons_noProblemDescriptors() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(List.of(scanEngine)); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(Collections.emptyList()); + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Skips gutter icon restoration when no issues exist in the map") + void testRestoreGutterIcons_noIssuesInMap() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(List.of(scanEngine)); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(List.of(problemDescriptor)); + when(problemHolderService.getAllIssues()).thenReturn(Collections.emptyMap()); + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Skips gutter icon restoration when no scan issues are found for the file") + void testRestoreGutterIcons_noScanIssuesForFile() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(List.of(scanEngine)); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(List.of(problemDescriptor)); + when(problemHolderService.getAllIssues()).thenReturn(Map.of("otherFile.java", List.of(scanIssue))); + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Skips gutter icon restoration when enabled engine has no scan issues") + void testRestoreGutterIcons_noEnabledEngineScanIssues() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(List.of(scanEngine)); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(List.of(problemDescriptor)); + when(problemHolderService.getAllIssues()).thenReturn(Map.of("/path/to/file.java", List.of(scanIssue))); + when(scanIssue.getScanEngine()).thenReturn(mock(ScanEngine.class)); // Not equal to scanEngine + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Handles null document case in restoreGutterIcons") + void testRestoreGutterIcons_documentNull() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class); + MockedStatic psiDocumentManagerMock = mockStatic(PsiDocumentManager.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(List.of(scanEngine)); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(List.of(problemDescriptor)); + when(problemHolderService.getAllIssues()).thenReturn(Map.of("/path/to/file.java", List.of(scanIssue))); + when(scanIssue.getScanEngine()).thenReturn(scanEngine); + PsiDocumentManager psiDocumentManager = mock(PsiDocumentManager.class); + psiDocumentManagerMock.when(() -> PsiDocumentManager.getInstance(project)).thenReturn(psiDocumentManager); + when(psiDocumentManager.getDocument(psiFile)).thenReturn(null); + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Successfully restores gutter icons") + void testRestoreGutterIcons_success() throws Exception { + try (MockedStatic devAssistUtilsMock = mockStatic(DevAssistUtils.class); + MockedStatic holderMock = mockStatic(ProblemHolderService.class); + MockedStatic psiDocumentManagerMock = mockStatic(PsiDocumentManager.class); + MockedStatic decoratorMock = mockStatic(ProblemDecorator.class)) { + com.checkmarx.intellij.devassist.configuration.GlobalScannerController controller = mock(com.checkmarx.intellij.devassist.configuration.GlobalScannerController.class); + devAssistUtilsMock.when(DevAssistUtils::globalScannerController).thenReturn(controller); + when(controller.getEnabledScanners()).thenReturn(List.of(scanEngine)); + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + when(problemHolderService.getProblemDescriptors(anyString())).thenReturn(List.of(problemDescriptor)); + when(problemHolderService.getAllIssues()).thenReturn(Map.of("/path/to/file.java", List.of(scanIssue))); + when(scanIssue.getScanEngine()).thenReturn(scanEngine); + PsiDocumentManager psiDocumentManager = mock(PsiDocumentManager.class); + psiDocumentManagerMock.when(() -> PsiDocumentManager.getInstance(project)).thenReturn(psiDocumentManager); + when(psiDocumentManager.getDocument(psiFile)).thenReturn(document); + ProblemDecorator decorator = mock(ProblemDecorator.class); + doNothing().when(decorator).restoreGutterIcons(any(), any(), any(), any()); + // Should reach the decorator call + DevAssistFileListenerTestHelper.invokeRestoreGutterIcons(project, psiFile, "/path/to/file.java"); + } + } + + @Test + @DisplayName("Filters scan issues correctly for enabled scanners") + void testGetScanIssuesForEnabledScanner_filtersCorrectly() throws Exception { + ScanEngine engine1 = mock(ScanEngine.class); + ScanEngine engine2 = mock(ScanEngine.class); + ScanIssue issue1 = mock(ScanIssue.class); + ScanIssue issue2 = mock(ScanIssue.class); + when(issue1.getScanEngine()).thenReturn(engine1); + when(issue2.getScanEngine()).thenReturn(engine2); + List result = DevAssistFileListenerTestHelper.invokeGetScanIssuesForEnabledScanner(List.of(engine1), List.of(issue1, issue2)); + assertEquals(1, result.size()); + assertTrue(result.contains(issue1)); + } + + @Test + @DisplayName("Does not call ProblemHolderService for null or empty path in removeProblemDescriptor") + void testRemoveProblemDescriptor_nullPath() { + DevAssistFileListener.removeProblemDescriptor(project, null); + DevAssistFileListener.removeProblemDescriptor(project, ""); + // Should not call ProblemHolderService.getInstance + } + + @Test + @DisplayName("Removes problem descriptors for valid file path in removeProblemDescriptor") + void testRemoveProblemDescriptor_validPath() { + try (MockedStatic holderMock = mockStatic(ProblemHolderService.class)) { + holderMock.when(() -> ProblemHolderService.getInstance(project)).thenReturn(problemHolderService); + DevAssistFileListener.removeProblemDescriptor(project, "/path/to/file.java"); + verify(problemHolderService, times(1)).removeProblemDescriptorsForFile("/path/to/file.java"); + } + } + // Helper class to invoke private static methods for coverage + static class DevAssistFileListenerTestHelper { + static void invokeRestoreGutterIcons(Project project, PsiFile psiFile, String filePath) throws Exception { + var method = DevAssistFileListener.class.getDeclaredMethod("restoreGutterIcons", Project.class, PsiFile.class, String.class); + method.setAccessible(true); + method.invoke(null, project, psiFile, filePath); + } + static List invokeGetScanIssuesForEnabledScanner(List enabledScanEngines, List scanIssueList) throws Exception { + var method = DevAssistFileListener.class.getDeclaredMethod("getScanIssuesForEnabledScanner", List.class, List.class); + method.setAccessible(true); + return (List) method.invoke(null, enabledScanEngines, scanIssueList); + } + } +} + + diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/mcp/McpConfigurationTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/mcp/McpConfigurationTest.java new file mode 100644 index 00000000..92d6acb1 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/mcp/McpConfigurationTest.java @@ -0,0 +1,231 @@ +package com.checkmarx.intellij.unit.devassist.mcp; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.commands.TenantSetting; +import com.checkmarx.intellij.devassist.configuration.mcp.McpInstallService; +import com.checkmarx.intellij.devassist.configuration.mcp.McpSettingsInjector; +import com.checkmarx.intellij.devassist.configuration.mcp.McpUninstallHandler; +import com.checkmarx.intellij.settings.global.GlobalSettingsSensitiveState; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.openapi.extensions.PluginId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class McpConfigurationTest { + + + private GlobalSettingsState mockGlobalState; + private GlobalSettingsSensitiveState mockSensitiveState; + private MockedStatic mockedGlobalState; + private MockedStatic mockedSensitiveState; + private MockedStatic mockedTenantSetting; + + @BeforeEach + void setUp() { + // Mock global settings + mockGlobalState = mock(GlobalSettingsState.class); + mockSensitiveState = mock(GlobalSettingsSensitiveState.class); + mockedGlobalState = mockStatic(GlobalSettingsState.class); + mockedSensitiveState = mockStatic(GlobalSettingsSensitiveState.class); + mockedTenantSetting = mockStatic(TenantSetting.class); + + mockedGlobalState.when(GlobalSettingsState::getInstance).thenReturn(mockGlobalState); + mockedSensitiveState.when(GlobalSettingsSensitiveState::getInstance).thenReturn(mockSensitiveState); + } + + @AfterEach + void tearDown() { + mockedGlobalState.close(); + mockedSensitiveState.close(); + mockedTenantSetting.close(); + } + + // ===== McpSettingsInjector Tests ===== + + @Test + @DisplayName("getMcpJsonPath_ReturnsValidPath") + void testGetMcpJsonPath_ReturnsValidPath() { + // Act + Path result = McpSettingsInjector.getMcpJsonPath(); + + // Assert + assertNotNull(result); + assertTrue(result.toString().contains("github-copilot")); + assertTrue(result.toString().contains("intellij")); + assertTrue(result.toString().endsWith("mcp.json")); + } + + @Test + @DisplayName("tokenParsing_ValidToken_ExtractsIssuer") + void testTokenParsing_ValidToken_ExtractsIssuer() { + // Test with different issuers to validate token creation flexibility + String[] testIssuers = { + "https://iam.checkmarx.com", + "https://iam.checkmarx.net", + null + }; + + for (String issuer : testIssuers) { + String token = createValidJwtToken(issuer); + + // Verify token structure is valid regardless of issuer + assertNotNull(token); + assertTrue(token.contains(".")); + String[] parts = token.split("\\."); + assertEquals(3, parts.length); // header.payload.signature + } + } + + @Test + @DisplayName("tokenParsing_InvalidToken_HandlesGracefully") + void testTokenParsing_InvalidToken_HandlesGracefully() { + // Test that invalid tokens don't cause exceptions when processed + String invalidToken = "invalid.token"; + + // The method should handle this gracefully (we test this indirectly through integration) + assertDoesNotThrow(() -> { + // This would be called internally by McpSettingsInjector + String[] parts = invalidToken.split("\\."); + assertTrue(parts.length >= 1); + }); + } + + @Test + @DisplayName("constants_ToolWindowId_HasExpectedValue") + void testConstants_ToolWindowId_HasExpectedValue() { + // Verify the constant used in MCP configuration has the expected value + assertNotNull(Constants.TOOL_WINDOW_ID); + // Verify it contains expected identifier for Checkmarx - this is the actual business logic test + assertTrue(Constants.TOOL_WINDOW_ID.toLowerCase().contains("checkmarx") || + Constants.TOOL_WINDOW_ID.toLowerCase().contains("ast"), + "Tool Window ID should contain 'checkmarx' or 'ast' identifier"); + } + + // ===== McpInstallService Tests ===== + + @Test + @DisplayName("installSilentlyAsync_EmptyCredential_ReturnsFalse") + void testInstallSilentlyAsync_EmptyCredential_ReturnsFalse() throws Exception { + // Act + CompletableFuture result = McpInstallService.installSilentlyAsync(""); + + // Assert + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + @DisplayName("installSilentlyAsync_NullCredential_ReturnsFalse") + void testInstallSilentlyAsync_NullCredential_ReturnsFalse() throws Exception { + // Act + CompletableFuture result = McpInstallService.installSilentlyAsync(null); + + // Assert + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + @DisplayName("installSilentlyAsync_BlankCredential_ReturnsFalse") + void testInstallSilentlyAsync_BlankCredential_ReturnsFalse() throws Exception { + // Act + CompletableFuture result = McpInstallService.installSilentlyAsync(" "); + + // Assert + assertEquals(Boolean.FALSE, result.get()); + } + + // ===== McpUninstallHandler Tests ===== + + @Test + @DisplayName("beforePluginUnload_CheckmarxPluginUpdate_DoesNothing") + void testBeforePluginUnload_CheckmarxPluginUpdate_DoesNothing() { + // Arrange + IdeaPluginDescriptor mockDescriptor = mock(IdeaPluginDescriptor.class); + PluginId mockPluginId = mock(PluginId.class); + when(mockDescriptor.getPluginId()).thenReturn(mockPluginId); + when(mockPluginId.getIdString()).thenReturn("com.checkmarx.checkmarx-ast-jetbrains-plugin"); + + McpUninstallHandler handler = new McpUninstallHandler(); + + // Act & Assert - should not throw exception during update + assertDoesNotThrow(() -> handler.beforePluginUnload(mockDescriptor, true)); + } + + @Test + @DisplayName("beforePluginUnload_CheckmarxPluginUninstall_CallsUninstaller") + void testBeforePluginUnload_CheckmarxPluginUninstall_CallsUninstaller() { + // Arrange + IdeaPluginDescriptor mockDescriptor = mock(IdeaPluginDescriptor.class); + PluginId mockPluginId = mock(PluginId.class); + when(mockDescriptor.getPluginId()).thenReturn(mockPluginId); + when(mockPluginId.getIdString()).thenReturn("com.checkmarx.checkmarx-ast-jetbrains-plugin"); + + McpUninstallHandler handler = new McpUninstallHandler(); + + try (MockedStatic mockedInjector = mockStatic(McpSettingsInjector.class)) { + mockedInjector.when(McpSettingsInjector::uninstallFromCopilot).thenReturn(true); + + // Act - isUpdate = false (actual uninstall) + assertDoesNotThrow(() -> handler.beforePluginUnload(mockDescriptor, false)); + + // Assert + mockedInjector.verify(McpSettingsInjector::uninstallFromCopilot); + } + } + + @Test + @DisplayName("beforePluginUnload_UninstallThrowsException_HandlesGracefully") + void testBeforePluginUnload_UninstallThrowsException_HandlesGracefully() { + // Arrange + IdeaPluginDescriptor mockDescriptor = mock(IdeaPluginDescriptor.class); + PluginId mockPluginId = mock(PluginId.class); + when(mockDescriptor.getPluginId()).thenReturn(mockPluginId); + when(mockPluginId.getIdString()).thenReturn("com.checkmarx.checkmarx-ast-jetbrains-plugin"); + + McpUninstallHandler handler = new McpUninstallHandler(); + + try (MockedStatic mockedInjector = mockStatic(McpSettingsInjector.class)) { + mockedInjector.when(McpSettingsInjector::uninstallFromCopilot) + .thenThrow(new RuntimeException("Uninstall failed")); + + // Act & Assert - should not throw exception + assertDoesNotThrow(() -> handler.beforePluginUnload(mockDescriptor, false)); + mockedInjector.verify(McpSettingsInjector::uninstallFromCopilot); + } + } + + + // ===== Helper Methods ===== + + private String createValidJwtToken(String issuer) { + try { + String header = "{\"typ\":\"JWT\",\"alg\":\"HS256\"}"; + String payload; + if (issuer != null) { + payload = "{\"iss\":\"" + issuer + "\",\"sub\":\"user\"}"; + } else { + payload = "{\"sub\":\"user\"}"; + } + + String encodedHeader = Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes(StandardCharsets.UTF_8)); + String encodedPayload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + + return encodedHeader + "." + encodedPayload + ".signature"; + } catch (Exception e) { + throw new RuntimeException("Failed to create test token", e); + } + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemBuilderTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemBuilderTest.java new file mode 100644 index 00000000..1a308748 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemBuilderTest.java @@ -0,0 +1,103 @@ +package com.checkmarx.intellij.unit.devassist.problems; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemBuilder; +import com.checkmarx.intellij.devassist.remediation.CxOneAssistFix; +import com.checkmarx.intellij.devassist.remediation.IgnoreAllThisTypeFix; +import com.checkmarx.intellij.devassist.remediation.IgnoreVulnerabilityFix; +import com.checkmarx.intellij.devassist.remediation.ViewDetailsFix; +import com.checkmarx.intellij.util.SeverityLevel; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProblemBuilderTest { + @Test + @DisplayName("build returns correct ProblemDescriptor for all severity levels and unknown severity") + void testBuild_functionality() throws Exception { + PsiFile psiFile = mock(PsiFile.class); + InspectionManager manager = mock(InspectionManager.class); + Document document = mock(Document.class); + int lineNumber = 1; + boolean isOnTheFly = true; + when(document.getLineStartOffset(lineNumber)).thenReturn(0); + when(document.getLineEndOffset(lineNumber)).thenReturn(10); + when(document.getTextLength()).thenReturn(10); + when(document.getText(any(TextRange.class))).thenReturn("test"); + when(document.getLineCount()).thenReturn(2); + when(document.getText()).thenReturn("test\nline2"); + java.lang.reflect.Method buildMethod = ProblemBuilder.class.getDeclaredMethod( + "build", PsiFile.class, InspectionManager.class, ScanIssue.class, Document.class, int.class, boolean.class); + buildMethod.setAccessible(true); + for (SeverityLevel level : SeverityLevel.values()) { + ScanIssue scanIssue = mock(ScanIssue.class); + when(scanIssue.getSeverity()).thenReturn(level.getSeverity()); + when(scanIssue.getDescription()).thenReturn("desc"); + when(scanIssue.getTitle()).thenReturn("title"); + when(scanIssue.getFilePath()).thenReturn("file.java"); + when(scanIssue.getScanEngine()).thenReturn(ScanEngine.OSS); + ProblemHighlightType expectedType = + level == SeverityLevel.MALICIOUS || level == SeverityLevel.CRITICAL || level == SeverityLevel.HIGH ? + ProblemHighlightType.GENERIC_ERROR : + level == SeverityLevel.MEDIUM ? ProblemHighlightType.WARNING : + ProblemHighlightType.WEAK_WARNING; + when(manager.createProblemDescriptor(eq(psiFile), any(TextRange.class), anyString(), eq(expectedType), eq(isOnTheFly), any(CxOneAssistFix.class), any(ViewDetailsFix.class))).thenReturn(mock(ProblemDescriptor.class)); + ProblemDescriptor descriptor = (ProblemDescriptor) buildMethod.invoke( + null, psiFile, manager, scanIssue, document, lineNumber, isOnTheFly); + assertNotNull(descriptor); + } + // Unknown severity + ScanIssue unknownIssue = mock(ScanIssue.class); + when(unknownIssue.getSeverity()).thenReturn("UNKNOWN"); + when(unknownIssue.getDescription()).thenReturn("desc"); + when(unknownIssue.getTitle()).thenReturn("title"); + when(unknownIssue.getFilePath()).thenReturn("file.java"); + when(unknownIssue.getScanEngine()).thenReturn(ScanEngine.OSS); + when(manager.createProblemDescriptor(eq(psiFile), any(TextRange.class), anyString(), eq(ProblemHighlightType.WEAK_WARNING), eq(isOnTheFly), any(CxOneAssistFix.class), any(ViewDetailsFix.class))).thenReturn(mock(ProblemDescriptor.class)); + ProblemDescriptor unknownDescriptor = (ProblemDescriptor) buildMethod.invoke( + null, psiFile, manager, unknownIssue, document, lineNumber, isOnTheFly); + assertNotNull(unknownDescriptor); + } + + + @Test + @DisplayName("determineHighlightType returns correct type for all severities and unknown") + void testDetermineHighlightType_functionality() throws Exception { + java.lang.reflect.Method method = ProblemBuilder.class.getDeclaredMethod("determineHighlightType", ScanIssue.class); + method.setAccessible(true); + for (SeverityLevel level : SeverityLevel.values()) { + ScanIssue scanIssue = mock(ScanIssue.class); + when(scanIssue.getSeverity()).thenReturn(level.getSeverity()); + ProblemHighlightType type = (ProblemHighlightType) method.invoke(null, scanIssue); + if (level == SeverityLevel.MALICIOUS || level == SeverityLevel.CRITICAL || level == SeverityLevel.HIGH) { + assertEquals(ProblemHighlightType.GENERIC_ERROR, type); + } else if (level == SeverityLevel.MEDIUM) { + assertEquals(ProblemHighlightType.WARNING, type); + } else if (level == SeverityLevel.LOW) { + assertEquals(ProblemHighlightType.WEAK_WARNING, type); + } + } + ScanIssue unknownIssue = mock(ScanIssue.class); + when(unknownIssue.getSeverity()).thenReturn("UNKNOWN"); + ProblemHighlightType type = (ProblemHighlightType) method.invoke(null, unknownIssue); + assertEquals(ProblemHighlightType.WEAK_WARNING, type); + } + + @Test + @DisplayName("initSeverityToHighlightMap initializes mapping correctly") + void testInitSeverityToHighlightMap_functionality() throws Exception { + java.lang.reflect.Method method = ProblemBuilder.class.getDeclaredMethod("initSeverityToHighlightMap"); + method.setAccessible(true); + method.invoke(null); + } +} + diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemDecoratorTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemDecoratorTest.java new file mode 100644 index 00000000..12ce06e9 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemDecoratorTest.java @@ -0,0 +1,327 @@ +package com.checkmarx.intellij.unit.devassist.problems; + +import com.checkmarx.intellij.devassist.model.Location; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemDecorator; +import com.checkmarx.intellij.util.SeverityLevel; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.markup.MarkupModel; +import com.intellij.openapi.editor.markup.RangeHighlighter; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.Application; +import com.intellij.openapi.util.TextRange; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import javax.swing.*; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ProblemDecoratorTest { + private ProblemDecorator decorator; + + @BeforeEach + void setUp() { + decorator = new ProblemDecorator(); + } + + // test severityHighlighterLayerMap initialization + @Test + @DisplayName("Test severityHighlighterLayerMap initialization") + void testSeverityHighlighterLayerMapInitialization() { + assertFalse(decorator.getSeverityHighlighterLayerMap().isEmpty()); + assertTrue(decorator.getSeverityHighlighterLayerMap().containsKey("Malicious")); + assertTrue(decorator.getSeverityHighlighterLayerMap().containsKey("Critical")); + assertTrue(decorator.getSeverityHighlighterLayerMap().containsKey("High")); + assertTrue(decorator.getSeverityHighlighterLayerMap().containsKey("Medium")); + assertTrue(decorator.getSeverityHighlighterLayerMap().containsKey("Low")); + } + + // test getGutterIconBasedOnStatus for all severities + @Test + @DisplayName("Test getGutterIconBasedOnStatus for all severities") + void testGetGutterIconBasedOnStatus_AllSeverities() { + for (SeverityLevel level : SeverityLevel.values()) { + Icon icon = decorator.getGutterIconBasedOnStatus(level.getSeverity()); + assertNotNull(icon, "Icon should not be null for severity: " + level.getSeverity()); + } + // Unknown severity string + Icon unknownIcon = decorator.getGutterIconBasedOnStatus("not-a-severity"); + assertNotNull(unknownIcon); + } + + // test determineHighlighterLayer for all severities + @Test + @DisplayName("Test determineHighlighterLayer for all severities") + void testDetermineHighlighterLayer_AllSeverities() { + for (SeverityLevel level : SeverityLevel.values()) { + ScanIssue issue = new ScanIssue(); + issue.setSeverity(level.getSeverity()); + Integer layer = decorator.determineHighlighterLayer(issue); + assertNotNull(layer); + } + // Unknown severity + ScanIssue unknown = new ScanIssue(); + unknown.setSeverity("not-a-severity"); + Integer layer = decorator.determineHighlighterLayer(unknown); + assertNotNull(layer); + } + + // test highlightLineAddGutterIconForProblem (corner cases: null editor, wrong document, empty locations) + @Test + @DisplayName("Test highlightLineAddGutterIconForProblem with corner cases") + void testHighlightLineAddGutterIconForProblem_CornerCases() { + Project project = mock(Project.class); + PsiFile psiFile = mock(PsiFile.class); + ScanIssue scanIssue = new ScanIssue(); + scanIssue.setLocations(new ArrayList<>()); // empty locations + boolean isProblem = true; + int problemLineNumber = 1; + try (MockedStatic appManager = Mockito.mockStatic(ApplicationManager.class); + MockedStatic fileEditorManager = Mockito.mockStatic(FileEditorManager.class); + MockedStatic psiDocManager = Mockito.mockStatic(PsiDocumentManager.class)) { + Application application = mock(Application.class); + //noinspection ResultOfMethodCallIgnored + appManager.when(ApplicationManager::getApplication).thenReturn(application); // restore scenario stub + Application capturedAppRestore = ApplicationManager.getApplication(); + assertSame(application, capturedAppRestore); + doAnswer(invocation -> { // run invokeLater immediately + Runnable r = invocation.getArgument(0); + r.run(); + return null; + }).when(application).invokeLater(any(Runnable.class)); + FileEditorManager fileMgr = mock(FileEditorManager.class); + fileEditorManager.when(() -> FileEditorManager.getInstance(project)).thenReturn(fileMgr); + when(fileMgr.getSelectedTextEditor()).thenReturn(null); // null editor path + decorator.highlightLineAddGutterIconForProblem(project, psiFile, scanIssue, isProblem, problemLineNumber); + // Non-null editor but mismatched document + Editor editor = mock(Editor.class); + when(fileMgr.getSelectedTextEditor()).thenReturn(editor); + Document doc = mock(Document.class); + when(editor.getDocument()).thenReturn(doc); + when(doc.getCharsSequence()).thenReturn("a".repeat(100)); + PsiDocumentManager psiDocMgr = mock(PsiDocumentManager.class); + psiDocManager.when(() -> PsiDocumentManager.getInstance(project)).thenReturn(psiDocMgr); + when(psiDocMgr.getDocument(psiFile)).thenReturn(mock(Document.class)); // different document so early return + decorator.highlightLineAddGutterIconForProblem(project, psiFile, scanIssue, isProblem, problemLineNumber); + } + } + + // test removeAllGutterIcons (corner cases: null editor, null highlighters) + @Test + @DisplayName("Test removeAllGutterIcons with corner cases") + void testRemoveAllGutterIcons_CornerCases() { + PsiFile psiFile = mock(PsiFile.class); + Project project = mock(Project.class); + when(psiFile.getProject()).thenReturn(project); + try (MockedStatic appManager = Mockito.mockStatic(ApplicationManager.class); + MockedStatic fileEditorManager = Mockito.mockStatic(FileEditorManager.class)) { + Application application = mock(Application.class); + //noinspection ResultOfMethodCallIgnored + appManager.when(ApplicationManager::getApplication).thenReturn(application); + Application capturedAppRemove = ApplicationManager.getApplication(); + assertSame(application, capturedAppRemove); + doAnswer(invocation -> { // run invokeLater immediately + Runnable r = invocation.getArgument(0); + r.run(); + return null; + }).when(application).invokeLater(any(Runnable.class)); + FileEditorManager fileMgr = mock(FileEditorManager.class); + fileEditorManager.when(() -> FileEditorManager.getInstance(project)).thenReturn(fileMgr); + when(fileMgr.getSelectedTextEditor()).thenReturn(null); // null editor path + decorator.removeAllGutterIcons(psiFile.getProject()); + // Non-null editor, empty highlighters array + Editor editor = mock(Editor.class); + when(fileMgr.getSelectedTextEditor()).thenReturn(editor); + MarkupModel markupModel = mock(MarkupModel.class); + when(editor.getMarkupModel()).thenReturn(markupModel); + RangeHighlighter[] empty = new RangeHighlighter[0]; + when(markupModel.getAllHighlighters()).thenReturn(empty); + decorator.removeAllGutterIcons(psiFile.getProject()); + } + } + + // test restoreGutterIcons (corner cases: empty scanIssueList, null elementAtLine) + @Test + @DisplayName("Test restoreGutterIcons with corner cases") + void testRestoreGutterIcons_CornerCases() { + Project project = mock(Project.class); + PsiFile psiFile = mock(PsiFile.class); + Document document = mock(Document.class); + when(document.getCharsSequence()).thenReturn("a".repeat(200)); + List scanIssueList = new ArrayList<>(); + decorator.restoreGutterIcons(project, psiFile, scanIssueList, document); // empty list path + // One scanIssue, elementAtLine null + ScanIssue issue = new ScanIssue(); + issue.setSeverity("High"); + Location location = new Location(1, 0, 10); + issue.setLocations(Collections.singletonList(location)); + issue.setTitle("TestTitle"); + scanIssueList.add(issue); + when(document.getLineStartOffset(anyInt())).thenReturn(0); + when(psiFile.findElementAt(anyInt())).thenReturn(null); // null element path + decorator.restoreGutterIcons(project, psiFile, scanIssueList, document); + // Second scenario: elementAtLine non-null triggers highlightLineAddGutterIconForProblem + PsiFile psiFile2 = mock(PsiFile.class); + when(psiFile2.getProject()).thenReturn(project); + ScanIssue issue2 = new ScanIssue(); + issue2.setSeverity("Low"); + issue2.setLocations(Collections.singletonList(location)); + issue2.setTitle("Title2"); + List list2 = Collections.singletonList(issue2); + PsiElement elementAt = mock(PsiElement.class); + when(document.getLineStartOffset(location.getLine())).thenReturn(0); + when(psiFile2.findElementAt(0)).thenReturn(elementAt); + try (MockedStatic appManager = Mockito.mockStatic(ApplicationManager.class); + MockedStatic fileEditorManager = Mockito.mockStatic(FileEditorManager.class); + MockedStatic psiDocManager = Mockito.mockStatic(PsiDocumentManager.class); + MockedStatic devUtilsMock = Mockito.mockStatic(DevAssistUtils.class)) { + devUtilsMock.when(() -> DevAssistUtils.getTextRangeForLine(any(Document.class), anyInt())) + .thenReturn(new TextRange(0,1)); + Application application = mock(Application.class); + appManager.when(ApplicationManager::getApplication).thenReturn(application); + Application capturedAppRestore = ApplicationManager.getApplication(); + assertSame(application, capturedAppRestore); + doAnswer(inv -> { Runnable r = inv.getArgument(0); r.run(); return null; }).when(application).invokeLater(any(Runnable.class)); + FileEditorManager fileMgr = mock(FileEditorManager.class); + fileEditorManager.when(() -> FileEditorManager.getInstance(project)).thenReturn(fileMgr); + Editor editor = mock(Editor.class); + when(fileMgr.getSelectedTextEditor()).thenReturn(editor); + Document doc2 = mock(Document.class); + when(editor.getDocument()).thenReturn(doc2); + PsiDocumentManager psiDocMgr = mock(PsiDocumentManager.class); + psiDocManager.when(() -> PsiDocumentManager.getInstance(project)).thenReturn(psiDocMgr); + when(psiDocMgr.getDocument(psiFile2)).thenReturn(doc2); + // locations iteration requires getLineStartOffset/End etc. + when(doc2.getLineStartOffset(location.getLine())).thenReturn(0); + when(doc2.getLineEndOffset(location.getLine())).thenReturn(5); + when(doc2.getTextLength()).thenReturn(10); + when(doc2.getLineCount()).thenReturn(2); + decorator.restoreGutterIcons(project, psiFile2, list2, doc2); + } + } + + @Test + @DisplayName("Test removeAllGutterIcons exception path") + void testRemoveAllGutterIcons_ExceptionPath() { + PsiFile psiFile = mock(PsiFile.class); + Project project = mock(Project.class); + when(psiFile.getProject()).thenReturn(project); + try (MockedStatic appManager = Mockito.mockStatic(ApplicationManager.class); + MockedStatic fileEditorManager = Mockito.mockStatic(FileEditorManager.class)) { + Application application = mock(Application.class); + //noinspection ResultOfMethodCallIgnored + appManager.when(ApplicationManager::getApplication).thenReturn(application); // exception path stub + Application capturedAppException = ApplicationManager.getApplication(); + assertSame(application, capturedAppException); + doAnswer(inv -> { + try { + throw new RuntimeException("boom"); + } catch (RuntimeException e) { + // swallow + } + return null; + }).when(application).invokeLater(any(Runnable.class)); + fileEditorManager.when(() -> FileEditorManager.getInstance(project)).thenThrow(new RuntimeException("manager fail")); + decorator.removeAllGutterIcons(psiFile.getProject()); // ensure no exception escapes + } + } + + @Test + @DisplayName("Test restoreGutterIcons catch block") + void testRestoreGutterIcons_CatchBlock() { + Project project = mock(Project.class); + PsiFile psiFile = mock(PsiFile.class); + Document document = mock(Document.class); + // Issue with empty locations to trigger IndexOutOfBoundsException when accessing get(0) + ScanIssue issue = new ScanIssue(); + issue.setSeverity(SeverityLevel.HIGH.getSeverity()); + issue.setLocations(Collections.emptyList()); + issue.setTitle("Title"); + List list = Collections.singletonList(issue); + decorator.restoreGutterIcons(project, psiFile, list, document); // should hit catch and continue + } + + @Test + @DisplayName("Test removeAllGutterIcons remove all branch") + void testRemoveAllGutterIcons_RemoveAllBranch() { + PsiFile psiFile = mock(PsiFile.class); + Project project = mock(Project.class); + when(psiFile.getProject()).thenReturn(project); + try (MockedStatic appManager = Mockito.mockStatic(ApplicationManager.class); + MockedStatic fileEditorManager = Mockito.mockStatic(FileEditorManager.class)) { + Application application = mock(Application.class); + //noinspection ResultOfMethodCallIgnored + appManager.when(ApplicationManager::getApplication).thenReturn(application); + Application capturedApp = ApplicationManager.getApplication(); + assertSame(application, capturedApp); + doAnswer(inv -> { Runnable r = inv.getArgument(0); r.run(); return null; }).when(application).invokeLater(any(Runnable.class)); + FileEditorManager fileMgr = mock(FileEditorManager.class); + fileEditorManager.when(() -> FileEditorManager.getInstance(project)).thenReturn(fileMgr); + Editor editor = mock(Editor.class); + when(fileMgr.getSelectedTextEditor()).thenReturn(editor); + MarkupModel markupModel = mock(MarkupModel.class); + when(editor.getMarkupModel()).thenReturn(markupModel); + RangeHighlighter h1 = mock(RangeHighlighter.class); + RangeHighlighter h2 = mock(RangeHighlighter.class); + when(markupModel.getAllHighlighters()).thenReturn(new RangeHighlighter[]{h1, h2}); + decorator.removeAllGutterIcons(psiFile.getProject()); + verify(markupModel, times(1)).removeAllHighlighters(); + } + } + + // test highlightLineAddGutterIconForProblem with multi-location + @Test + @DisplayName("Test highlightLineAddGutterIconForProblem with multi-location") + void testHighlightLineAddGutterIconForProblem_MultiLocation() { + Project project = mock(Project.class); + PsiFile psiFile = mock(PsiFile.class); + Location location1 = new Location(1, 0, 10); + Location location2 = new Location(2, 0, 10); + Location location3 = new Location(3, 0, 10); + ScanIssue scanIssue = new ScanIssue(); + scanIssue.setLocations(Arrays.asList(location1, location2, location3)); + boolean isProblem = true; + int problemLineNumber = 1; + try (MockedStatic appManager = Mockito.mockStatic(ApplicationManager.class); + MockedStatic fileEditorManager = Mockito.mockStatic(FileEditorManager.class); + MockedStatic psiDocManager = Mockito.mockStatic(PsiDocumentManager.class)) { + Application application = mock(Application.class); + //noinspection ResultOfMethodCallIgnored + appManager.when(ApplicationManager::getApplication).thenReturn(application); // restore scenario stub + Application capturedAppRestore = ApplicationManager.getApplication(); + assertSame(application, capturedAppRestore); + doAnswer(invocation -> { // run invokeLater immediately + Runnable r = invocation.getArgument(0); + r.run(); + return null; + }).when(application).invokeLater(any(Runnable.class)); + FileEditorManager fileMgr = mock(FileEditorManager.class); + fileEditorManager.when(() -> FileEditorManager.getInstance(project)).thenReturn(fileMgr); + when(fileMgr.getSelectedTextEditor()).thenReturn(null); // null editor path + decorator.highlightLineAddGutterIconForProblem(project, psiFile, scanIssue, isProblem, problemLineNumber); + // Non-null editor but mismatched document + Editor editor = mock(Editor.class); + when(fileMgr.getSelectedTextEditor()).thenReturn(editor); + Document doc = mock(Document.class); + when(editor.getDocument()).thenReturn(doc); + when(doc.getCharsSequence()).thenReturn("a".repeat(100)); + PsiDocumentManager psiDocMgr = mock(PsiDocumentManager.class); + psiDocManager.when(() -> PsiDocumentManager.getInstance(project)).thenReturn(psiDocMgr); + when(psiDocMgr.getDocument(psiFile)).thenReturn(mock(Document.class)); // different document so early return + decorator.highlightLineAddGutterIconForProblem(project, psiFile, scanIssue, isProblem, problemLineNumber); + } + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemHolderServiceTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemHolderServiceTest.java new file mode 100644 index 00000000..f881a138 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ProblemHolderServiceTest.java @@ -0,0 +1,113 @@ +package com.checkmarx.intellij.unit.devassist.problems; +import com.checkmarx.intellij.devassist.problems.ProblemHolderService; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.util.messages.MessageBus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProblemHolderServiceTest { + + private ProblemHolderService service; + private Project mockProject; + private MessageBus messageBus; + + @BeforeEach + void setUp() { + mockProject = mock(Project.class); + messageBus = mock(MessageBus.class); + + // Return the mocked message bus and a mocked IssueListener publisher + doReturn(messageBus).when(mockProject).getMessageBus(); + ProblemHolderService.IssueListener publisher = mock(ProblemHolderService.IssueListener.class); + when(messageBus.syncPublisher(ProblemHolderService.ISSUE_TOPIC)).thenReturn(publisher); + + service = new ProblemHolderService(mockProject); + + // Ensure project.getService(...) returns this service instance + when(mockProject.getService(ProblemHolderService.class)).thenReturn(service); + } + + @Test + void testAddProblems_ValidInput() { + String filePath = "testFile.java"; + List issues = Collections.singletonList(new ScanIssue()); + + service.addProblems(filePath, issues); + + Map> allIssues = service.getAllIssues(); + assertTrue(allIssues.containsKey(filePath)); + assertEquals(1, allIssues.get(filePath).size()); + } + + @Test + void testGetAllIssues_Empty() { + Map> allIssues = service.getAllIssues(); + assertTrue(allIssues.isEmpty()); + } + + @Test + void testRemoveAllProblemsOfType_ValidType() { + String filePath = "testFile.java"; + ScanIssue issue = mock(ScanIssue.class); + when(issue.getScanEngine()).thenReturn(ScanEngine.OSS); + service.addProblems(filePath, Collections.singletonList(issue)); + + service.removeAllProblemsOfType("OSS"); + assertTrue(service.getAllIssues().get(filePath).isEmpty()); + } + + @Test + void testGetProblemDescriptors_NoDescriptors() { + List descriptors = service.getProblemDescriptors("nonExistentFile.java"); + assertTrue(descriptors.isEmpty()); + } + + @Test + void testAddProblemDescriptors_ValidInput() { + String filePath = "testFile.java"; + ProblemDescriptor descriptor = mock(ProblemDescriptor.class); + service.addProblemDescriptors(filePath, Collections.singletonList(descriptor)); + + List descriptors = service.getProblemDescriptors(filePath); + assertEquals(1, descriptors.size()); + } + + @Test + void testRemoveProblemDescriptorsForFile_ValidFile() { + String filePath = "testFile.java"; + ProblemDescriptor descriptor = mock(ProblemDescriptor.class); + service.addProblemDescriptors(filePath, Collections.singletonList(descriptor)); + + service.removeProblemDescriptorsForFile(filePath); + assertTrue(service.getProblemDescriptors(filePath).isEmpty()); + } + + @Test + void testAddToCxOneFindings_ValidInput() { + PsiFile mockFile = mock(PsiFile.class); + VirtualFile vf = mock(VirtualFile.class); + + when(mockFile.getProject()).thenReturn(mockProject); + when(mockFile.getVirtualFile()).thenReturn(vf); + when(vf.getPath()).thenReturn("testFile.java"); + + List issues = Collections.singletonList(new ScanIssue()); + + // Call the static helper which uses project.getService(...) internally (we stubbed it in setUp) + ProblemHolderService.addToCxOneFindings(mockFile, issues); + + Map> allIssues = service.getAllIssues(); + assertTrue(allIssues.containsKey("testFile.java")); + assertEquals(1, allIssues.get("testFile.java").size()); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ScanIssueProcessorTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ScanIssueProcessorTest.java new file mode 100644 index 00000000..e5628af2 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/problems/ScanIssueProcessorTest.java @@ -0,0 +1,262 @@ +package com.checkmarx.intellij.unit.devassist.problems; + +import com.checkmarx.intellij.devassist.model.Location; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.problems.ProblemDecorator; +import com.checkmarx.intellij.devassist.problems.ProblemHelper; +import com.checkmarx.intellij.devassist.problems.ScanIssueProcessor; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; // add scan engine import +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; // added +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ScanIssueProcessorTest { + + private ProblemDecorator problemDecorator; + private PsiFile psiFile; + private InspectionManager inspectionManager; + private Document document; + private Project project; + private ProblemHelper problemHelper; + private ScanIssueProcessor processorViaHelper; + + @BeforeEach + void setUp() { + problemDecorator = mock(ProblemDecorator.class); + psiFile = mock(PsiFile.class); + inspectionManager = mock(InspectionManager.class); + document = mock(Document.class); + project = mock(Project.class); + problemHelper = mock(ProblemHelper.class); + + when(problemHelper.getFile()).thenReturn(psiFile); + when(problemHelper.getManager()).thenReturn(inspectionManager); + when(problemHelper.getDocument()).thenReturn(document); + when(problemHelper.isOnTheFly()).thenReturn(true); + when(psiFile.getProject()).thenReturn(project); + + processorViaHelper = new ScanIssueProcessor(problemHelper, problemDecorator); + } + + private ScanIssue buildIssue(int line, String severity, String title) { + ScanIssue issue = new ScanIssue(); + issue.setSeverity(severity); + issue.setTitle(title); + issue.setScanEngine(ScanEngine.OSS); // ensure non-null to prevent NPE in fixes + List locations = new ArrayList<>(); + locations.add(new Location(line, 0, 0)); + issue.setLocations(locations); + return issue; + } + + @Test + @DisplayName("ScanIssue without locations should return null and not interact with decorator") + void testProcessScanIssue_invalidNoLocations() { + ScanIssue issue = new ScanIssue(); + issue.setTitle("noLoc"); + issue.setLocations(null); + assertNull(processorViaHelper.processScanIssue(issue)); + verifyNoInteractions(problemDecorator); + } + + @Test + @DisplayName("ScanIssue with empty locations list should return null") + void testProcessScanIssue_invalidEmptyLocations() { + ScanIssue issue = new ScanIssue(); + issue.setTitle("emptyLoc"); + issue.setLocations(Collections.emptyList()); + assertNull(processorViaHelper.processScanIssue(issue)); + verifyNoInteractions(problemDecorator); + } + + @Test + @DisplayName("Line out of range should skip processing and not highlight") + void testProcessScanIssue_invalidLineOutOfRange() { + ScanIssue issue = buildIssue(5, "HIGH", "outOfRange"); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(5, document)).thenReturn(true); + assertNull(processorViaHelper.processScanIssue(issue)); + utils.verify(() -> DevAssistUtils.isLineOutOfRange(5, document)); + } + verifyNoInteractions(problemDecorator); + } + + @Test + @DisplayName("Blank severity should invalidate issue") + void testProcessScanIssue_invalidBlankSeverity() { + ScanIssue issue = buildIssue(1, " ", "blankSeverity"); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(1, document)).thenReturn(false); + assertNull(processorViaHelper.processScanIssue(issue)); + } + verifyNoInteractions(problemDecorator); + } + + @Test + @DisplayName("Null severity should invalidate issue") + void testProcessScanIssue_invalidNullSeverity() { + ScanIssue issue = buildIssue(2, null, "nullSeverity"); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(2, document)).thenReturn(false); + assertNull(processorViaHelper.processScanIssue(issue)); + } + verifyNoInteractions(problemDecorator); + } + + @Test + @DisplayName("Non-problem element present: highlight only, no descriptor") + void testProcessValidIssue_isProblemFalse_elementPresent() { + ScanIssue issue = buildIssue(8, "LOW", "nonProblemElement"); + when(document.getLineStartOffset(8)).thenReturn(80); + PsiElement element = mock(PsiElement.class); + when(psiFile.findElementAt(80)).thenReturn(element); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(8, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("low")).thenReturn(false); + assertNull(processorViaHelper.processScanIssue(issue)); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("Non-problem element absent: no highlight, no descriptor") + void testProcessValidIssue_isProblemFalse_elementAbsent() { + ScanIssue issue = buildIssue(9, "LOW", "nonProblemNoElement"); + when(document.getLineStartOffset(9)).thenReturn(90); + when(psiFile.findElementAt(90)).thenReturn(null); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(9, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("low")).thenReturn(false); + assertNull(processorViaHelper.processScanIssue(issue)); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("High severity skipped (forced non-problem) with element: non-problem highlight") + void testProcessValidIssue_descriptorSkipped_elementPresent() { // renamed from isProblemTrue_descriptorSuccess_elementPresent + ScanIssue issue = buildIssue(3, "HIGH", "validProblem"); + when(document.getLineStartOffset(3)).thenReturn(30); + PsiElement element = mock(PsiElement.class); + when(psiFile.findElementAt(30)).thenReturn(element); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(3, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("high")).thenReturn(false); // force skip + ProblemDescriptor result = processorViaHelper.processScanIssue(issue); + assertNull(result); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("High severity skipped (forced non-problem) without element: nothing highlighted") + void testProcessValidIssue_descriptorSkipped_elementAbsent() { // renamed from isProblemTrue_descriptorSuccess_elementAbsent + ScanIssue issue = buildIssue(6, "HIGH", "noElementProblem"); + when(document.getLineStartOffset(6)).thenReturn(60); + when(psiFile.findElementAt(60)).thenReturn(null); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(6, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("high")).thenReturn(false); + ProblemDescriptor result = processorViaHelper.processScanIssue(issue); + assertNull(result); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("Descriptor null skipped scenario with element: non-problem highlight") + void testProcessValidIssue_descriptorNullSkipped_elementPresent() { // renamed + ScanIssue issue = buildIssue(5, "HIGH", "descriptorNull"); + when(document.getLineStartOffset(5)).thenReturn(50); + PsiElement element = mock(PsiElement.class); + when(psiFile.findElementAt(50)).thenReturn(element); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(5, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("high")).thenReturn(false); // skip + ProblemDescriptor result = processorViaHelper.processScanIssue(issue); + assertNull(result); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("Descriptor null skipped scenario without element: no highlight") + void testProcessValidIssue_descriptorNullSkipped_elementAbsent() { // renamed + ScanIssue issue = buildIssue(15, "HIGH", "descriptorNullNoElement"); + when(document.getLineStartOffset(15)).thenReturn(150); + when(psiFile.findElementAt(150)).thenReturn(null); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(15, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("high")).thenReturn(false); + ProblemDescriptor result = processorViaHelper.processScanIssue(issue); + assertNull(result); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("Multiple locations: first used, descriptor skipped, element present") + void testProcessScanIssue_multipleLocations_firstLineUsedSkippedDescriptor() { // renamed + ScanIssue issue = new ScanIssue(); + issue.setSeverity("CRITICAL"); + issue.setTitle("multiLocation"); + issue.setScanEngine(ScanEngine.OSS); + issue.setLocations(Arrays.asList(new Location(20,0,0), new Location(999,0,0))); + when(document.getLineStartOffset(20)).thenReturn(200); + PsiElement element = mock(PsiElement.class); + when(psiFile.findElementAt(200)).thenReturn(element); + try (MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.isLineOutOfRange(20, document)).thenReturn(false); + utils.when(() -> DevAssistUtils.isProblem("critical")).thenReturn(false); // skip descriptor + ProblemDescriptor result = processorViaHelper.processScanIssue(issue); + assertNull(result); + verify(problemDecorator, never()).highlightLineAddGutterIconForProblem(any(), any(), any(), anyBoolean(), anyInt()); + } + } + + @Test + @DisplayName("Constructor via ProblemHelper should initialize processor") + void testConstructor_problemHelper() { + assertNotNull(processorViaHelper); + } + + @Test + @DisplayName("Direct constructor should initialize processor") + void testConstructor_direct() { + ScanIssueProcessor direct = new ScanIssueProcessor(problemDecorator, psiFile, inspectionManager, document, false); + assertNotNull(direct); + } + + private void stubManagerCreateProblemDescriptor(ProblemDescriptor returnValue) { + when(inspectionManager.createProblemDescriptor( + any(PsiFile.class), + any(TextRange.class), + anyString(), + any(ProblemHighlightType.class), + anyBoolean(), + any(LocalQuickFix.class), + any(LocalQuickFix.class), + any(LocalQuickFix.class), + any(LocalQuickFix.class) + )).thenReturn(returnValue); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/registry/ScannerRegistryTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/registry/ScannerRegistryTest.java new file mode 100644 index 00000000..fa11a0e2 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/registry/ScannerRegistryTest.java @@ -0,0 +1,202 @@ +package com.checkmarx.intellij.unit.devassist.registry; + +import com.checkmarx.intellij.devassist.registry.ScannerRegistry; +import com.checkmarx.intellij.devassist.basescanner.ScannerCommand; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.openapi.project.Project; +import org.junit.jupiter.api.*; +import java.lang.reflect.*; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ScannerRegistryTest { + private Project project; + private ScannerRegistry registry; + + private static class FakeScanner implements ScannerCommand { + int registerCalls; + int deregisterCalls; + boolean disposed; + + @Override + public void register(Project p) { registerCalls++; } + + @Override + public void deregister(Project p) { deregisterCalls++; } + + @Override + public void dispose() { disposed = true; } + } + + @BeforeEach + void setUp() { + project = mock(Project.class, RETURNS_DEEP_STUBS); + when(project.getName()).thenReturn("TestProject"); + registry = new ScannerRegistry(project); + try { + Field f = ScannerRegistry.class.getDeclaredField("scannerMap"); + f.setAccessible(true); + @SuppressWarnings("unchecked") Map map = (Map) f.get(registry); + map.put(ScanEngine.OSS.name(), new FakeScanner()); + } catch (Exception e) { + fail("Reflection setup failed: " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private Map scannerMap() throws Exception { + Field f=ScannerRegistry.class.getDeclaredField("scannerMap"); + f.setAccessible(true); + return (Map)f.get(registry); + } + + private void putScanner(String id, ScannerCommand sc) throws Exception { + scannerMap().put(id, sc); + } + + @Test + @DisplayName("Constructor initializes OSS scanner") + void testScannerRegistry_constructorInitializesOssScanner_functionality(){ + assertNotNull(registry.getScanner(ScanEngine.OSS.name())); + } + + @Test + @DisplayName("getScanner returns existing scanner") + void testGetScanner_existingReturnsNonNull_functionality(){ + assertNotNull(registry.getScanner(ScanEngine.OSS.name())); + } + + @Test + @DisplayName("getScanner returns null when id missing") + void testGetScanner_missingReturnsNull_functionality(){ + assertNull(registry.getScanner("UNKNOWN")); + } + + @Test + @DisplayName("registerScanner invokes register on existing scanner") + void testRegisterScanner_existingInvokesRegister_functionality() throws Exception { + FakeScanner fake=new FakeScanner(); + putScanner(ScanEngine.OSS.name(),fake); + registry.registerScanner(ScanEngine.OSS.name()); + assertEquals(1,fake.registerCalls); + } + + @Test + @DisplayName("registerScanner does nothing for unknown id") + void testRegisterScanner_unknownNoAction_functionality(){ + registry.registerScanner("BAD_ID"); + } + + @Test + @DisplayName("registerAllScanners invokes register on every scanner") + void testRegisterAllScanners_invokesRegisterForEveryEntry_functionality() throws Exception { + FakeScanner s1=new FakeScanner(); + FakeScanner s2=new FakeScanner(); + putScanner("S1",s1); + putScanner("S2",s2); + registry.registerAllScanners(project); + assertEquals(1,s1.registerCalls); + assertEquals(1,s2.registerCalls); + } + + @Test + @DisplayName("registerAllScanners with empty map does nothing") + void testRegisterAllScanners_emptyNoAction_functionality() throws Exception { + scannerMap().clear(); + registry.registerAllScanners(project); + } + + @Test + @DisplayName("deregisterScanner invokes deregister and dispose") + void testDeregisterScanner_existingInvokesDeregisterAndDispose_functionality() throws Exception { + FakeScanner fake=new FakeScanner(); + putScanner(ScanEngine.OSS.name(),fake); + registry.deregisterScanner(ScanEngine.OSS.name()); + assertEquals(1,fake.deregisterCalls); + assertTrue(fake.disposed); + } + + @Test + @DisplayName("deregisterScanner unknown id does nothing") + void testDeregisterScanner_unknownNoAction_functionality(){ + registry.deregisterScanner("BAD_ID"); + } + + @Test + @DisplayName("deregisterAllScanners invokes deregister on each scanner without disposing") + void testDeregisterAllScanners_invokesDeregisterForEveryEntry_functionality() throws Exception { + FakeScanner s1=new FakeScanner(); + FakeScanner s2=new FakeScanner(); + putScanner("S1",s1); + putScanner("S2",s2); + registry.deregisterAllScanners(); + assertEquals(1,s1.deregisterCalls); + assertEquals(1,s2.deregisterCalls); + assertFalse(s1.disposed); + assertFalse(s2.disposed); + } + + @Test + @DisplayName("deregisterAllScanners empty map does nothing") + void testDeregisterAllScanners_emptyNoAction_functionality() throws Exception { + scannerMap().clear(); + registry.deregisterAllScanners(); + } + + @Test + @DisplayName("dispose deregisters all scanners and clears map") + void testDispose_clearsMapAndDeregistersScanners_functionality() throws Exception { + FakeScanner s1=new FakeScanner(); + FakeScanner s2=new FakeScanner(); + putScanner("S1",s1); + putScanner("S2",s2); + registry.dispose(); + assertEquals(1,s1.deregisterCalls); + assertEquals(1,s2.deregisterCalls); + assertTrue(scannerMap().isEmpty()); + } + + @Test + @DisplayName("scannerInitialization populates OSS scanner") + void testScannerInitialization_populatesOssScanner_functionality(){ + assertNotNull(registry.getScanner(ScanEngine.OSS.name())); + } + + @Test + @DisplayName("setScanner private method adds scanner to map") + void testSetScanner_privateAddsScannerToMap_functionality() throws Exception { + Method m=ScannerRegistry.class.getDeclaredMethod("setScanner",String.class,ScannerCommand.class); + m.setAccessible(true); + FakeScanner fake=new FakeScanner(); + m.invoke(registry,"CUSTOM",fake); + assertSame(fake,registry.getScanner("CUSTOM")); + } + + @Test + @DisplayName("getScanner returns same instance (identity)") + void testGetScanner_identity_functionality() throws Exception { + FakeScanner fake=new FakeScanner(); + putScanner("IDENTITY",fake); + assertSame(fake, registry.getScanner("IDENTITY")); + } + + @Test + @DisplayName("dispose called twice leaves map empty and no exception") + void testDispose_idempotent_functionality() throws Exception { + registry.dispose(); + registry.dispose(); + assertTrue(scannerMap().isEmpty()); + } + + @Test + @DisplayName("registerScanner after dispose has no effect") + void testRegisterScanner_afterDisposeNoEffect_functionality() throws Exception { + FakeScanner fake=new FakeScanner(); + putScanner(ScanEngine.OSS.name(),fake); + registry.dispose(); + fake.registerCalls=0; + registry.registerScanner(ScanEngine.OSS.name()); + assertEquals(0,fake.registerCalls); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/CxOneAssistFixTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/CxOneAssistFixTest.java new file mode 100644 index 00000000..a9dd851b --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/CxOneAssistFixTest.java @@ -0,0 +1,92 @@ +package com.checkmarx.intellij.unit.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.CxOneAssistFix; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class CxOneAssistFixTest { + + private Project project; + private ProblemDescriptor descriptor; + + @BeforeEach + void setUp() { + project = mock(Project.class, RETURNS_DEEP_STUBS); + descriptor = mock(ProblemDescriptor.class); + } + + @Test + @DisplayName("Constructor stores scanIssue reference") + void testConstructor_functionality() { + ScanIssue issue = new ScanIssue(); + CxOneAssistFix fix = new CxOneAssistFix(issue); + assertSame(issue, fix.getScanIssue()); + } + + @Test + @DisplayName("getFamilyName returns expected constant") + void testGetFamilyName_functionality() { + CxOneAssistFix fix = new CxOneAssistFix(new ScanIssue()); + assertEquals(Constants.RealTimeConstants.FIX_WITH_CXONE_ASSIST, fix.getFamilyName()); + } + + @Test + @DisplayName("getIcon returns star action icon") + void testGetIcon_functionality() { + CxOneAssistFix fix = new CxOneAssistFix(new ScanIssue()); + Icon icon = fix.getIcon(0); + assertNotNull(icon); + assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("applyFix OSS branch throws NPE in headless env (notification requires Application)") + void testApplyFix_ossBranch_functionality() { + ScanIssue issue = new ScanIssue(); + issue.setScanEngine(ScanEngine.OSS); + issue.setTitle("OSS Title"); + CxOneAssistFix fix = new CxOneAssistFix(issue); + assertThrows(NullPointerException.class, () -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix routes to ASCA remediation branch") + void testApplyFix_ascaBranch_functionality() { + ScanIssue issue = new ScanIssue(); + issue.setScanEngine(ScanEngine.ASCA); + issue.setTitle("ASCA Title"); + CxOneAssistFix fix = new CxOneAssistFix(issue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix with other engine does nothing in default case") + void testApplyFix_otherEngineDefaultNoAction_functionality() { + ScanIssue issue = new ScanIssue(); + issue.setScanEngine(ScanEngine.IAC); // engine not explicitly handled + issue.setTitle("IAC Title"); + CxOneAssistFix fix = new CxOneAssistFix(issue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix with null scanEngine throws NPE (current behavior)") + void testApplyFix_nullScanEngineThrowsNpe_functionality() { + ScanIssue issue = new ScanIssue(); // scanEngine left null + issue.setTitle("Null Engine Title"); + CxOneAssistFix fix = new CxOneAssistFix(issue); + assertThrows(NullPointerException.class, () -> fix.applyFix(project, descriptor)); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/IgnoreAllThisTypeFixTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/IgnoreAllThisTypeFixTest.java new file mode 100644 index 00000000..d7a0bca1 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/IgnoreAllThisTypeFixTest.java @@ -0,0 +1,105 @@ +package com.checkmarx.intellij.unit.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.IgnoreAllThisTypeFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class IgnoreAllThisTypeFixTest { + + private Project project; + private ProblemDescriptor descriptor; + private ScanIssue scanIssue; + + @BeforeEach + void setUp() { + project = mock(Project.class, RETURNS_DEEP_STUBS); + descriptor = mock(ProblemDescriptor.class); + scanIssue = new ScanIssue(); + scanIssue.setTitle("Sample Title"); + } + + @Test + @DisplayName("Constructor creates instance without error") + void testConstructor_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + assertNotNull(fix); + } + + @Test + @DisplayName("Constructor stores scanIssue reference (reflection check)") + void testConstructor_storesScanIssue_functionality() throws Exception { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + Field f = IgnoreAllThisTypeFix.class.getDeclaredField("scanIssue"); + f.setAccessible(true); + Object stored = f.get(fix); + assertSame(scanIssue, stored); + } + + @Test + @DisplayName("getFamilyName returns expected constant string") + void testGetFamilyName_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + assertEquals(Constants.RealTimeConstants.IGNORE_ALL_OF_THIS_TYPE_FIX_NAME, fix.getFamilyName()); + } + + @Test + @DisplayName("getFamilyName is non-null") + void testGetFamilyName_nonNull_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + assertNotNull(fix.getFamilyName()); + } + + @Test + @DisplayName("getIcon returns STAR_ACTION icon for visibility flag") + void testGetIcon_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + Icon icon = fix.getIcon(Iconable.ICON_FLAG_VISIBILITY); + assertNotNull(icon); + assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("getIcon returns same icon for combined read+visibility flags") + void testGetIcon_withFlags_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + int flags = Iconable.ICON_FLAG_READ_STATUS | Iconable.ICON_FLAG_VISIBILITY; + Icon icon = fix.getIcon(flags); + assertNotNull(icon); + assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("applyFix executes without throwing when title present") + void testApplyFix_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix executes without throwing when title is null") + void testApplyFix_nullTitle_functionality() { + scanIssue.setTitle(null); // simulate missing title + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(scanIssue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix throws NullPointerException when scanIssue is null (edge case)") + void testApplyFix_nullScanIssue_functionality() { + IgnoreAllThisTypeFix fix = new IgnoreAllThisTypeFix(null); // allowed by constructor signature + assertThrows(NullPointerException.class, () -> fix.applyFix(project, descriptor)); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/IgnoreVulnerabilityFixTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/IgnoreVulnerabilityFixTest.java new file mode 100644 index 00000000..bf1d466b --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/IgnoreVulnerabilityFixTest.java @@ -0,0 +1,95 @@ +package com.checkmarx.intellij.unit.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.IgnoreVulnerabilityFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class IgnoreVulnerabilityFixTest { + + private Project project; + private ProblemDescriptor descriptor; + private ScanIssue issue; + + @BeforeEach + void setUp(){ + project = mock(Project.class, RETURNS_DEEP_STUBS); + descriptor = mock(ProblemDescriptor.class); + issue = new ScanIssue(); + issue.setTitle("Vuln Title"); + } + + @Test + @DisplayName("Constructor creates instance with non-null ScanIssue") + void testConstructor_functionality(){ + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + assertNotNull(fix); + } + + @Test + @DisplayName("Constructor stores scanIssue field (reflection identity)") + void testConstructor_storesScanIssue_functionality() throws Exception { + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + Field f = IgnoreVulnerabilityFix.class.getDeclaredField("scanIssue"); + f.setAccessible(true); + assertSame(issue, f.get(fix)); + } + + @Test + @DisplayName("getFamilyName returns expected constant") + void testGetFamilyName_functionality(){ + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + assertEquals(Constants.RealTimeConstants.IGNORE_THIS_VULNERABILITY_FIX_NAME, fix.getFamilyName()); + } + + @Test + @DisplayName("getIcon returns STAR_ACTION for visibility flag") + void testGetIcon_visibilityFlag_functionality(){ + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + Icon icon = fix.getIcon(Iconable.ICON_FLAG_VISIBILITY); + assertNotNull(icon); assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("getIcon returns STAR_ACTION for combined flags") + void testGetIcon_combinedFlags_functionality(){ + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + int flags = Iconable.ICON_FLAG_READ_STATUS | Iconable.ICON_FLAG_VISIBILITY; + Icon icon = fix.getIcon(flags); + assertNotNull(icon); assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("applyFix logs info without throwing when title present") + void testApplyFix_functionality(){ + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix handles null title gracefully") + void testApplyFix_nullTitle_functionality(){ + issue.setTitle(null); + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(issue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix throws NullPointerException when scanIssue is null") + void testApplyFix_nullScanIssueThrowsNpe_functionality(){ + IgnoreVulnerabilityFix fix = new IgnoreVulnerabilityFix(null); // will dereference scanIssue.getTitle + assertThrows(NullPointerException.class, () -> fix.applyFix(project, descriptor)); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/RemediationManagerTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/RemediationManagerTest.java new file mode 100644 index 00000000..8ab5ded2 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/RemediationManagerTest.java @@ -0,0 +1,148 @@ +package com.checkmarx.intellij.unit.devassist.remediation; + +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.RemediationManager; +import com.checkmarx.intellij.devassist.remediation.prompts.CxOneAssistFixPrompts; +import com.checkmarx.intellij.devassist.remediation.prompts.ViewDetailsPrompts; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.openapi.project.Project; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +import static org.mockito.Mockito.*; + +@DisplayName("RemediationManager unit tests covering all branches") +public class RemediationManagerTest { + + @Test + @DisplayName("testFixWithCxOneAssist_OSS_CopySuccess") + void testFixWithCxOneAssist_OSS_CopySuccess() { + Project project = mock(Project.class); + ScanIssue issue = buildScanIssue(ScanEngine.OSS); + RemediationManager manager = new RemediationManager(); + + try (MockedStatic fixPrompts = mockStatic(CxOneAssistFixPrompts.class); + MockedStatic devAssist = mockStatic(DevAssistUtils.class)) { + fixPrompts.when(() -> CxOneAssistFixPrompts.scaRemediationPrompt( + anyString(), anyString(), anyString(), anyString())).thenReturn("prompt"); + devAssist.when(() -> DevAssistUtils.copyToClipboardWithNotification(anyString(), anyString(), anyString(), any())) + .thenReturn(true); + + manager.fixWithCxOneAssist(project, issue); + + fixPrompts.verify(() -> CxOneAssistFixPrompts.scaRemediationPrompt( + eq(issue.getTitle()), eq(issue.getPackageVersion()), eq(issue.getPackageManager()), eq(issue.getSeverity()))); + devAssist.verify(() -> DevAssistUtils.copyToClipboardWithNotification(eq("prompt"), anyString(), anyString(), eq(project))); + } + } + + @Test + @DisplayName("testFixWithCxOneAssist_OSS_CopyFailure") + void testFixWithCxOneAssist_OSS_CopyFailure() { + Project project = mock(Project.class); + ScanIssue issue = buildScanIssue(ScanEngine.OSS); + RemediationManager manager = new RemediationManager(); + + try (MockedStatic fixPrompts = mockStatic(CxOneAssistFixPrompts.class); + MockedStatic devAssist = mockStatic(DevAssistUtils.class)) { + fixPrompts.when(() -> CxOneAssistFixPrompts.scaRemediationPrompt(anyString(), anyString(), anyString(), anyString())) + .thenReturn("prompt"); + devAssist.when(() -> DevAssistUtils.copyToClipboardWithNotification(anyString(), anyString(), anyString(), any())) + .thenReturn(false); + + manager.fixWithCxOneAssist(project, issue); + + fixPrompts.verify(() -> CxOneAssistFixPrompts.scaRemediationPrompt( + eq(issue.getTitle()), eq(issue.getPackageVersion()), eq(issue.getPackageManager()), eq(issue.getSeverity()))); + devAssist.verify(() -> DevAssistUtils.copyToClipboardWithNotification(eq("prompt"), anyString(), anyString(), eq(project))); + } + } + + @Test + @DisplayName("testFixWithCxOneAssist_ASCA_Branch") + void testFixWithCxOneAssist_ASCA_Branch() { + Project project = mock(Project.class); + ScanIssue issue = buildScanIssue(ScanEngine.ASCA); + RemediationManager manager = new RemediationManager(); + manager.fixWithCxOneAssist(project, issue); + } + + @Test + @DisplayName("testFixWithCxOneAssist_DefaultBranch") + void testFixWithCxOneAssist_DefaultBranch() { + Project project = mock(Project.class); + RemediationManager manager = new RemediationManager(); + for (ScanEngine engine : new ScanEngine[]{ScanEngine.SECRETS, ScanEngine.CONTAINERS, ScanEngine.IAC}) { + ScanIssue issue = buildScanIssue(engine); + manager.fixWithCxOneAssist(project, issue); + } + } + + @Test + @DisplayName("testViewDetails_OSS_CopySuccess") + void testViewDetails_OSS_CopySuccess() { + Project project = mock(Project.class); + ScanIssue issue = buildScanIssue(ScanEngine.OSS); + RemediationManager manager = new RemediationManager(); + + try (MockedStatic viewPrompts = mockStatic(ViewDetailsPrompts.class); + MockedStatic devAssist = mockStatic(DevAssistUtils.class)) { + viewPrompts.when(() -> ViewDetailsPrompts.generateSCAExplanationPrompt(anyString(), anyString(), anyString(), any())) + .thenReturn("viewPrompt"); + devAssist.when(() -> DevAssistUtils.copyToClipboardWithNotification(anyString(), anyString(), anyString(), any())) + .thenReturn(true); + + manager.viewDetails(project, issue); + + viewPrompts.verify(() -> ViewDetailsPrompts.generateSCAExplanationPrompt( + eq(issue.getTitle()), eq(issue.getPackageVersion()), eq(issue.getSeverity()), eq(issue.getVulnerabilities()))); + devAssist.verify(() -> DevAssistUtils.copyToClipboardWithNotification(eq("viewPrompt"), anyString(), anyString(), eq(project))); + } + } + + @Test + @DisplayName("testViewDetails_OSS_CopyFailure") + void testViewDetails_OSS_CopyFailure() { + Project project = mock(Project.class); + ScanIssue issue = buildScanIssue(ScanEngine.OSS); + RemediationManager manager = new RemediationManager(); + + try (MockedStatic viewPrompts = mockStatic(ViewDetailsPrompts.class); + MockedStatic devAssist = mockStatic(DevAssistUtils.class)) { + viewPrompts.when(() -> ViewDetailsPrompts.generateSCAExplanationPrompt(anyString(), anyString(), anyString(), any())) + .thenReturn("viewPrompt"); + devAssist.when(() -> DevAssistUtils.copyToClipboardWithNotification(anyString(), anyString(), anyString(), any())) + .thenReturn(false); + + manager.viewDetails(project, issue); + + viewPrompts.verify(() -> ViewDetailsPrompts.generateSCAExplanationPrompt( + eq(issue.getTitle()), eq(issue.getPackageVersion()), eq(issue.getSeverity()), eq(issue.getVulnerabilities()))); + devAssist.verify(() -> DevAssistUtils.copyToClipboardWithNotification(eq("viewPrompt"), anyString(), anyString(), eq(project))); + } + } + + @Test + @DisplayName("testViewDetails_ASCA_Branch") + void testViewDetails_ASCA_Branch() { + Project project = mock(Project.class); + ScanIssue issue = buildScanIssue(ScanEngine.ASCA); + RemediationManager manager = new RemediationManager(); + manager.viewDetails(project, issue); + } + + private static ScanIssue buildScanIssue(ScanEngine engine) { + ScanIssue issue = new ScanIssue(); + issue.setSeverity("High"); + issue.setTitle("VulnTitle"); + issue.setDescription("Desc"); + issue.setRemediationAdvise("Advise"); + issue.setPackageVersion("1.0.0"); + issue.setPackageManager("npm"); + issue.setCve("CVE-123"); + issue.setScanEngine(engine); + issue.setFilePath("/path/file"); + return issue; + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/ViewDetailsFixTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/ViewDetailsFixTest.java new file mode 100644 index 00000000..16c6a226 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/ViewDetailsFixTest.java @@ -0,0 +1,106 @@ +package com.checkmarx.intellij.unit.devassist.remediation; + +import com.checkmarx.intellij.Constants; +import com.checkmarx.intellij.CxIcons; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.remediation.ViewDetailsFix; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Iconable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ViewDetailsFixTest { + + private Project project; + private ProblemDescriptor descriptor; + private ScanIssue issue; + + @BeforeEach + void setUp() { + project = mock(Project.class, RETURNS_DEEP_STUBS); + descriptor = mock(ProblemDescriptor.class); + issue = new ScanIssue(); + issue.setTitle("Detail Title"); + issue.setScanEngine(ScanEngine.ASCA); + } + + @Test + @DisplayName("Constructor creates instance with non-null ScanIssue") + void testConstructor_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(issue); + assertNotNull(fix); + } + + @Test + @DisplayName("Constructor stores scanIssue field (reflection identity)") + void testConstructor_storesScanIssue_functionality() throws Exception { + ViewDetailsFix fix = new ViewDetailsFix(issue); + Field f = ViewDetailsFix.class.getDeclaredField("scanIssue"); + f.setAccessible(true); + assertSame(issue, f.get(fix)); + } + + @Test + @DisplayName("getFamilyName returns expected constant") + void testGetFamilyName_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(issue); + assertEquals(Constants.RealTimeConstants.VIEW_DETAILS_FIX_NAME, fix.getFamilyName()); + } + + @Test + @DisplayName("getFamilyName is non-null") + void testGetFamilyName_nonNull_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(issue); + assertNotNull(fix.getFamilyName()); + } + + @Test + @DisplayName("getIcon returns STAR_ACTION for visibility flag") + void testGetIcon_visibilityFlag_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(issue); + Icon icon = fix.getIcon(Iconable.ICON_FLAG_VISIBILITY); + assertNotNull(icon); + assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("getIcon returns STAR_ACTION for combined flags") + void testGetIcon_combinedFlags_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(issue); + int flags = Iconable.ICON_FLAG_VISIBILITY | Iconable.ICON_FLAG_READ_STATUS; + Icon icon = fix.getIcon(flags); + assertNotNull(icon); + assertEquals(CxIcons.STAR_ACTION, icon); + } + + @Test + @DisplayName("applyFix completes without throwing when title present") + void testApplyFix_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(issue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix completes without throwing when title is null") + void testApplyFix_nullTitle_functionality() { + issue.setTitle(null); + ViewDetailsFix fix = new ViewDetailsFix(issue); + assertDoesNotThrow(() -> fix.applyFix(project, descriptor)); + } + + @Test + @DisplayName("applyFix throws NullPointerException when scanIssue is null") + void testApplyFix_nullScanIssueThrowsNpe_functionality() { + ViewDetailsFix fix = new ViewDetailsFix(null); // dereferences scanIssue.getTitle internally + assertThrows(NullPointerException.class, () -> fix.applyFix(project, descriptor)); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/prompts/CxOneAssistFixPromptsTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/prompts/CxOneAssistFixPromptsTest.java new file mode 100644 index 00000000..a3a9e6a8 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/prompts/CxOneAssistFixPromptsTest.java @@ -0,0 +1,86 @@ +package com.checkmarx.intellij.unit.devassist.remediation.prompts; + +import com.checkmarx.intellij.devassist.remediation.prompts.CxOneAssistFixPrompts; +import com.checkmarx.intellij.util.SeverityLevel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("CxOneAssistFixPrompts Tests - Full Branch Coverage") +public class CxOneAssistFixPromptsTest { + + @Test + @DisplayName("scaRemediationPrompt_IncludesAllDynamicValues") + void scaRemediationPrompt_IncludesAllDynamicValues() { + String pkg = "lodash"; + String version = "4.17.21"; + String manager = "npm"; + String severity = SeverityLevel.HIGH.getSeverity(); + + String prompt = CxOneAssistFixPrompts.scaRemediationPrompt(pkg, version, manager, severity); + + assertAll( + () -> assertTrue(prompt.contains(pkg + "@" + version), "Should embed package@version"), + () -> assertTrue(prompt.contains("package manager: `" + manager + "`"), "Should embed package manager"), + () -> assertTrue(prompt.contains("**Severity:** `" + severity + "`"), "Should embed severity"), + () -> assertTrue(prompt.contains("Remediation Summary"), "Should contain remediation summary section"), + () -> assertTrue(prompt.contains("Remediation failed for " + pkg + "@" + version), "Should include failure path wording"), + () -> assertTrue(prompt.contains("Remediation completed for " + pkg + "@" + version), "Should include success path wording") + ); + } + + @Test + @DisplayName("scaRemediationPrompt_ContainsJsonToolInvocationBlock") + void scaRemediationPrompt_ContainsJsonToolInvocationBlock() { + String prompt = CxOneAssistFixPrompts.scaRemediationPrompt("express", "1.2.3", "maven", "Critical"); + assertTrue(prompt.contains("```json"), "Should contain json fenced block start"); + assertTrue(prompt.contains("\"packageName\": \"express\""), "JSON should include packageName"); + assertTrue(prompt.contains("\"packageVersion\": \"1.2.3\""), "JSON should include packageVersion"); + assertTrue(prompt.contains("\"packageManager\": \"maven\""), "JSON should include packageManager"); + } + + @Test + @DisplayName("generateSecretRemediationPrompt_NullDescriptionAndSeverity_GracefulFallback") + void generateSecretRemediationPrompt_NullDescriptionAndSeverity_GracefulFallback() { + String title = "HARD_CODED_SECRET"; + String prompt = CxOneAssistFixPrompts.generateSecretRemediationPrompt(title, null, null); + assertTrue(prompt.contains("A secret has been detected: \"" + title + "\""), "Should mention title"); + assertTrue(prompt.contains("Severity level: ``"), "Severity line should show empty backticks for null severity"); + assertTrue(prompt.contains("Likely invalid"), "Fallback assessment should be for invalid secret"); + } + + @Test + @DisplayName("generateSecretRemediationPrompt_CriticalSeverity_AssessmentBranch") + void generateSecretRemediationPrompt_CriticalSeverity_AssessmentBranch() { + String prompt = CxOneAssistFixPrompts.generateSecretRemediationPrompt("DB_PASSWORD", "desc", SeverityLevel.CRITICAL.getSeverity()); + assertTrue(prompt.contains("Confirmed valid secret"), "Critical severity should map to confirmed valid"); + } + + @Test + @DisplayName("generateSecretRemediationPrompt_HighSeverity_AssessmentBranch") + void generateSecretRemediationPrompt_HighSeverity_AssessmentBranch() { + String prompt = CxOneAssistFixPrompts.generateSecretRemediationPrompt("API_KEY", "desc", SeverityLevel.HIGH.getSeverity()); + assertTrue(prompt.contains("Possibly valid"), "High severity should map to possibly valid branch"); + } + + @Test + @DisplayName("generateSecretRemediationPrompt_LowSeverity_AssessmentBranch") + void generateSecretRemediationPrompt_LowSeverity_AssessmentBranch() { + String prompt = CxOneAssistFixPrompts.generateSecretRemediationPrompt("TEST_KEY", "desc", SeverityLevel.LOW.getSeverity()); + assertTrue(prompt.contains("Likely invalid"), "Low severity should map to likely invalid branch"); + } + + @Test + @DisplayName("generateSecretRemediationPrompt_IncludesStructuredMarkdownSections") + void generateSecretRemediationPrompt_IncludesStructuredMarkdownSections() { + String prompt = CxOneAssistFixPrompts.generateSecretRemediationPrompt("SECRET_TOKEN", "description here", SeverityLevel.MALICIOUS.getSeverity()); + assertAll( + () -> assertTrue(prompt.contains("Secret Remediation Summary"), "Should include summary header"), + () -> assertTrue(prompt.contains("Remediation Actions Taken"), "Should list remediation actions section"), + () -> assertTrue(prompt.contains("Next Steps"), "Should include Next Steps section"), + () -> assertTrue(prompt.contains("Best Practices"), "Should include best practices section"), + () -> assertTrue(prompt.contains("CONSTRAINTS"), "Should include constraints section") + ); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/prompts/ViewDetailsPromptsTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/prompts/ViewDetailsPromptsTest.java new file mode 100644 index 00000000..67e3adf1 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/remediation/prompts/ViewDetailsPromptsTest.java @@ -0,0 +1,97 @@ +package com.checkmarx.intellij.unit.devassist.remediation.prompts; + +import com.checkmarx.intellij.devassist.model.Vulnerability; +import com.checkmarx.intellij.devassist.remediation.prompts.ViewDetailsPrompts; +import com.checkmarx.intellij.util.SeverityLevel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ViewDetailsPrompts Tests - Full Branch Coverage") +public class ViewDetailsPromptsTest { + + @Test + @DisplayName("privateConstructor_ThrowsIllegalStateException") + void privateConstructor_ThrowsIllegalStateException() { + Constructor ctor = ViewDetailsPrompts.class.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + try { + ctor.newInstance(); + fail("Expected IllegalStateException to be thrown"); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + assertInstanceOf(IllegalStateException.class, cause, "Cause should be IllegalStateException"); + assertTrue(cause.getMessage().contains("Cannot instantiate")); + } catch (Exception e) { + fail("Unexpected exception type: " + e.getClass()); + } + } + + @Test + @DisplayName("generateSCAExplanationPrompt_MaliciousBranch_ContentAndVersionReference") + void generateSCAExplanationPrompt_MaliciousBranch_ContentAndVersionReference() { + String version = "9.9.9"; + String pkg = "evil-lib"; + String prompt = ViewDetailsPrompts.generateSCAExplanationPrompt(pkg, version, SeverityLevel.MALICIOUS.getSeverity(), List.of()); + assertAll( + () -> assertTrue(prompt.contains("Malicious Package Detected"), "Should include malicious header"), + () -> assertTrue(prompt.contains("Never install or use"), "Should warn against use"), + () -> assertTrue(prompt.contains(version), "Version should appear in guidance"), + () -> assertFalse(prompt.contains("Known Vulnerabilities"), "Should not include vulnerability section when malicious") + ); + } + + @Test + @DisplayName("generateSCAExplanationPrompt_MaliciousBranch_CaseInsensitiveMatch") + void generateSCAExplanationPrompt_MaliciousBranch_CaseInsensitiveMatch() { + String prompt = ViewDetailsPrompts.generateSCAExplanationPrompt("pkg", "1.0", SeverityLevel.MALICIOUS.getSeverity().toUpperCase(), List.of()); + assertTrue(prompt.contains("Malicious Package Detected"), "Upper-case MALICIOUS should trigger malicious branch"); + } + + @Test + @DisplayName("generateSCAExplanationPrompt_VulnerabilitiesBranch_WithList") + void generateSCAExplanationPrompt_VulnerabilitiesBranch_WithList() { + List vulns = new ArrayList<>(); + vulns.add(new Vulnerability("CVE-123", "desc1", "High", "adv1", "2.0.0")); + vulns.add(new Vulnerability("CVE-456", "desc2", "Medium", "adv2", "3.0.0")); + String prompt = ViewDetailsPrompts.generateSCAExplanationPrompt("safe-lib", "1.2.3", "vulnerable", vulns); + assertAll( + () -> assertTrue(prompt.contains("Known Vulnerabilities"), "Should include vulnerability section"), + () -> assertTrue(prompt.contains("CVE-123"), "First CVE should be listed"), + () -> assertTrue(prompt.contains("CVE-456"), "Second CVE should be listed"), + () -> assertTrue(prompt.contains("desc1"), "First description should appear"), + () -> assertTrue(prompt.contains("desc2"), "Second description should appear") + ); + } + + @Test + @DisplayName("generateSCAExplanationPrompt_VulnerabilitiesBranch_EmptyList") + void generateSCAExplanationPrompt_VulnerabilitiesBranch_EmptyList() { + String prompt = ViewDetailsPrompts.generateSCAExplanationPrompt("pkg", "0.0.1", "vulnerable", List.of()); + assertTrue(prompt.contains("No CVEs were provided"), "Empty list should trigger 'No CVEs' message"); + } + + @Test + @DisplayName("generateSCAExplanationPrompt_VulnerabilitiesBranch_NullList") + void generateSCAExplanationPrompt_VulnerabilitiesBranch_NullList() { + String prompt = ViewDetailsPrompts.generateSCAExplanationPrompt("pkg", "0.0.2", "vulnerable", null); + assertTrue(prompt.contains("No CVEs were provided"), "Null list should trigger 'No CVEs' message"); + } + + @Test + @DisplayName("generateSCAExplanationPrompt_CommonSectionsAlwaysPresent") + void generateSCAExplanationPrompt_CommonSectionsAlwaysPresent() { + String prompt = ViewDetailsPrompts.generateSCAExplanationPrompt("shared-lib", "2.3.4", "vulnerable", null); + assertAll( + () -> assertTrue(prompt.contains("Remediation Guidance"), "Remediation guidance section should appear"), + () -> assertTrue(prompt.contains("Summary Section"), "Summary section should appear"), + () -> assertTrue(prompt.contains("Output Formatting"), "Output formatting section should appear") + ); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScanResultAdaptorTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScanResultAdaptorTest.java new file mode 100644 index 00000000..0f6094a9 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScanResultAdaptorTest.java @@ -0,0 +1,142 @@ +package com.checkmarx.intellij.unit.devassist.scanners.oss; + +import com.checkmarx.ast.ossrealtime.OssRealtimeResults; +import com.checkmarx.ast.ossrealtime.OssRealtimeScanPackage; +import com.checkmarx.ast.ossrealtime.OssRealtimeVulnerability; +import com.checkmarx.ast.realtime.RealtimeLocation; +import com.checkmarx.intellij.devassist.model.Location; +import com.checkmarx.intellij.devassist.model.ScanIssue; +import com.checkmarx.intellij.devassist.scanners.oss.OssScanResultAdaptor; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class OssScanResultAdaptorTest { + + @Test + @DisplayName("getIssues_resultsNull_returnsEmptyList") + void testGetIssues_resultsNull_returnsEmptyList() { + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(null); + assertTrue(adaptor.getIssues().isEmpty()); + } + + @Test + @DisplayName("getIssues_packagesNull_returnsEmptyList") + void testGetIssues_packagesNull_returnsEmptyList() { + OssRealtimeResults results = mock(OssRealtimeResults.class); + when(results.getPackages()).thenReturn(null); // packages null + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + assertTrue(adaptor.getIssues().isEmpty()); + } + + @Test + @DisplayName("getIssues_packagesEmpty_returnsEmptyList") + void testGetIssues_packagesEmpty_returnsEmptyList() { + OssRealtimeResults results = mock(OssRealtimeResults.class); + when(results.getPackages()).thenReturn(List.of()); // empty list + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + assertTrue(adaptor.getIssues().isEmpty()); + } + + @Test + @DisplayName("getIssues_singlePackageNoLocationsNoVulns_fieldsMapped") + void testGetIssues_singlePackageNoLocationsNoVulns_fieldsMapped() { + OssRealtimeScanPackage pkg = mock(OssRealtimeScanPackage.class); + when(pkg.getPackageName()).thenReturn("mypackage"); + when(pkg.getPackageVersion()).thenReturn("1.2.3"); + when(pkg.getStatus()).thenReturn("LOW"); + when(pkg.getLocations()).thenReturn(List.of()); + when(pkg.getVulnerabilities()).thenReturn(List.of()); + OssRealtimeResults results = mock(OssRealtimeResults.class); + when(results.getPackages()).thenReturn(List.of(pkg)); + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + List issues = adaptor.getIssues(); + assertEquals(1, issues.size()); + ScanIssue issue = issues.get(0); + assertEquals("mypackage", issue.getTitle()); + assertEquals("1.2.3", issue.getPackageVersion()); + assertEquals("LOW", issue.getSeverity()); + assertEquals(ScanEngine.OSS, issue.getScanEngine()); + assertTrue(issue.getLocations().isEmpty()); + assertTrue(issue.getVulnerabilities().isEmpty()); + } + + @Test + @DisplayName("getIssues_singlePackageWithLocationsAndVulns_mappingAndLineIncrement") + void testGetIssues_singlePackageWithLocationsAndVulns_mappingAndLineIncrement() { + // Mock vulnerability + OssRealtimeVulnerability vul = mock(OssRealtimeVulnerability.class); + doReturn("Test vulnerability").when(vul).getDescription(); + doReturn("CRITICAL").when(vul).getSeverity(); + doReturn("9.9.9").when(vul).getFixVersion(); + // Mock location (line zero-based 0 should become 1) + RealtimeLocation loc = mock(RealtimeLocation.class); + doReturn(0).when(loc).getLine(); + doReturn(2).when(loc).getStartIndex(); + doReturn(5).when(loc).getEndIndex(); + OssRealtimeScanPackage pkg = mock(OssRealtimeScanPackage.class); + when(pkg.getPackageName()).thenReturn("libA"); + when(pkg.getPackageVersion()).thenReturn("0.0.1"); + when(pkg.getStatus()).thenReturn("HIGH"); + when(pkg.getLocations()).thenReturn(List.of(loc)); + when(pkg.getVulnerabilities()).thenReturn(List.of(vul)); + OssRealtimeResults results = mock(OssRealtimeResults.class); + when(results.getPackages()).thenReturn(List.of(pkg)); + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + List issues = adaptor.getIssues(); + assertEquals(1, issues.size()); + ScanIssue issue = issues.get(0); + assertEquals("libA", issue.getTitle()); + assertEquals("0.0.1", issue.getPackageVersion()); + assertEquals("HIGH", issue.getSeverity()); + assertEquals(ScanEngine.OSS, issue.getScanEngine()); + assertEquals(1, issue.getLocations().size()); + Location mappedLoc = issue.getLocations().get(0); + assertEquals(1, mappedLoc.getLine()); // incremented + assertEquals(2, mappedLoc.getStartIndex()); + assertEquals(5, mappedLoc.getEndIndex()); + assertEquals(1, issue.getVulnerabilities().size()); + var mappedVul = issue.getVulnerabilities().get(0); + assertEquals("Test vulnerability", mappedVul.getDescription()); + assertEquals("CRITICAL", mappedVul.getSeverity()); + assertEquals("9.9.9", mappedVul.getFixVersion()); + assertEquals("", mappedVul.getRemediationAdvise()); // adaptor sets empty advise + } + + @Test + @DisplayName("getIssues_multiplePackages_mixedLists_allCollected") + void testGetIssues_multiplePackages_mixedLists_allCollected() { + OssRealtimeScanPackage pkg1 = mock(OssRealtimeScanPackage.class); + when(pkg1.getPackageName()).thenReturn("pkg1"); + when(pkg1.getPackageVersion()).thenReturn("1.0"); + when(pkg1.getStatus()).thenReturn("LOW"); + when(pkg1.getLocations()).thenReturn(null); // null lists should be treated as empty + when(pkg1.getVulnerabilities()).thenReturn(null); + OssRealtimeScanPackage pkg2 = mock(OssRealtimeScanPackage.class); + when(pkg2.getPackageName()).thenReturn("pkg2"); + when(pkg2.getPackageVersion()).thenReturn("2.0"); + when(pkg2.getStatus()).thenReturn("MEDIUM"); + when(pkg2.getLocations()).thenReturn(List.of()); + when(pkg2.getVulnerabilities()).thenReturn(List.of()); + OssRealtimeResults results = mock(OssRealtimeResults.class); + when(results.getPackages()).thenReturn(List.of(pkg1, pkg2)); + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + List issues = adaptor.getIssues(); + assertEquals(2, issues.size()); + assertEquals("pkg1", issues.get(0).getTitle()); + assertEquals("pkg2", issues.get(1).getTitle()); + } + + @Test + @DisplayName("getResults_returnsOriginalInstance") + void testGetResults_returnsOriginalInstance() { + OssRealtimeResults results = mock(OssRealtimeResults.class); + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + assertSame(results, adaptor.getResults()); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScannerCommandTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScannerCommandTest.java new file mode 100644 index 00000000..3f1060f3 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScannerCommandTest.java @@ -0,0 +1,128 @@ +package com.checkmarx.intellij.unit.devassist.scanners.oss; + +import com.checkmarx.intellij.devassist.scanners.oss.OssScannerCommand; +import com.checkmarx.intellij.devassist.scanners.oss.OssScannerService; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ProjectRootManager; +import com.intellij.openapi.vfs.VirtualFile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class OssScannerCommandTest { + + private static class TestOssScannerCommand extends OssScannerCommand { + public TestOssScannerCommand(Disposable parentDisposable, Project project) { super(parentDisposable, project); } + @Override + protected com.intellij.openapi.vfs.VirtualFile findVirtualFile(String path) { return null; } // avoid LocalFileSystem.getInstance() + public void invokeInitializeScanner() { super.initializeScanner(); } + } + + private Project project; + private Disposable parentDisposable; + private OssScannerService ossScannerServiceSpy; + private TestOssScannerCommand command; + private void invokePrivateScan(OssScannerCommand cmd) throws Exception { + Method m = OssScannerCommand.class.getDeclaredMethod("scanAllManifestFilesInFolder"); + m.setAccessible(true); + m.invoke(cmd); + } + + @BeforeEach + void setUp() throws Exception { + project = mock(Project.class, RETURNS_DEEP_STUBS); + parentDisposable = mock(Disposable.class); + command = new TestOssScannerCommand(parentDisposable, project); + // Spy on internal service field for later verification + Field f = OssScannerCommand.class.getDeclaredField("ossScannerService"); + f.setAccessible(true); + OssScannerService original = (OssScannerService) f.get(command); + ossScannerServiceSpy = spy(original); + f.set(command, ossScannerServiceSpy); + } + + @Test + @DisplayName("Constructor initializes internal service and project reference") + void testConstructor_functionality() throws Exception { + Field f = OssScannerCommand.class.getDeclaredField("ossScannerService"); + f.setAccessible(true); + assertNotNull(f.get(command)); + Field p = OssScannerCommand.class.getDeclaredField("project"); + p.setAccessible(true); + assertSame(project, p.get(command)); + } + + @Test + @DisplayName("initializeScanner queues background task (NPE expected in headless env)") + void testInitializeScanner_connectivityQueuesTask_functionality() { + assertThrows(NullPointerException.class, () -> command.invokeInitializeScanner()); + } + + @Test + @DisplayName("scanAllManifestFilesInFolder with empty content roots performs no scans") + void testScanAllManifestFiles_emptyRoots_functionality() throws Exception { + ProjectRootManager prm = mock(ProjectRootManager.class); + when(prm.getContentRoots()).thenReturn(new VirtualFile[0]); + try (MockedStatic pm = mockStatic(ProjectRootManager.class)) { + pm.when(() -> ProjectRootManager.getInstance(project)).thenReturn(prm); + invokePrivateScan(command); + verifyNoInteractions(ossScannerServiceSpy); + } + } + + @Test + @DisplayName("scanAllManifestFilesInFolder with single non-matching file performs no scans") + void testScanAllManifestFiles_singleNonMatch_functionality() throws Exception { + VirtualFile root = mock(VirtualFile.class); + when(root.isDirectory()).thenReturn(false); + when(root.getPath()).thenReturn("/some/random/file.txt"); + when(root.exists()).thenReturn(true); + + ProjectRootManager prm = mock(ProjectRootManager.class); + when(prm.getContentRoots()).thenReturn(new VirtualFile[]{root}); + + try (MockedStatic pm = mockStatic(ProjectRootManager.class)) { + pm.when(() -> ProjectRootManager.getInstance(project)).thenReturn(prm); + invokePrivateScan(command); + verifyNoInteractions(ossScannerServiceSpy); + } + } + + @Test + @DisplayName("scanAllManifestFilesInFolder with matching manifest path but overridden findVirtualFile yields graceful no-op") + void testScanAllManifestFiles_matchingManifestGraceful_functionality() throws Exception { + VirtualFile root = mock(VirtualFile.class); + when(root.isDirectory()).thenReturn(false); + when(root.getPath()).thenReturn("/workspace/package.json"); + when(root.exists()).thenReturn(true); + ProjectRootManager prm = mock(ProjectRootManager.class); + when(prm.getContentRoots()).thenReturn(new VirtualFile[]{root}); + try (MockedStatic pm = mockStatic(ProjectRootManager.class)) { + pm.when(() -> ProjectRootManager.getInstance(project)).thenReturn(prm); + assertDoesNotThrow(() -> invokePrivateScan(command)); + } + } + + @Test + @DisplayName("scanAllManifestFilesInFolder handles matching manifest without LocalFileSystem (graceful)") + void testScanAllManifestFiles_exceptionDuringScan_functionality() throws Exception { + VirtualFile root = mock(VirtualFile.class); + when(root.isDirectory()).thenReturn(false); + when(root.getPath()).thenReturn("/workspace/pom.xml"); // another manifest pattern + when(root.exists()).thenReturn(true); + ProjectRootManager prm = mock(ProjectRootManager.class); + when(prm.getContentRoots()).thenReturn(new VirtualFile[]{root}); + try (MockedStatic pm = mockStatic(ProjectRootManager.class)) { + pm.when(() -> ProjectRootManager.getInstance(project)).thenReturn(prm); + assertDoesNotThrow(() -> invokePrivateScan(command)); + } + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScannerServiceTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScannerServiceTest.java new file mode 100644 index 00000000..02eab613 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/scanners/oss/OssScannerServiceTest.java @@ -0,0 +1,211 @@ +package com.checkmarx.intellij.unit.devassist.scanners.oss; + +import com.checkmarx.ast.ossrealtime.OssRealtimeResults; +import com.checkmarx.ast.ossrealtime.OssRealtimeScanPackage; +import com.checkmarx.ast.ossrealtime.OssRealtimeVulnerability; +import com.checkmarx.ast.realtime.RealtimeLocation; +import com.checkmarx.ast.wrapper.CxWrapper; +import com.checkmarx.intellij.devassist.scanners.oss.OssScanResultAdaptor; +import com.checkmarx.intellij.devassist.scanners.oss.OssScannerService; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.checkmarx.intellij.settings.global.CxWrapperFactory; +import com.intellij.psi.PsiFile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class OssScannerServiceTest { + private static class TestableOssScannerService extends OssScannerService { + private final Path overrideTemp; + TestableOssScannerService(Path overrideTemp){ this.overrideTemp = overrideTemp; } + @Override + protected Path getTempSubFolderPath(@SuppressWarnings("NullableProblems") PsiFile file) { + return overrideTemp; + } + } + private static class NoDeleteOssScannerService extends OssScannerService { + private final Path overrideTemp; + NoDeleteOssScannerService(Path overrideTemp){ this.overrideTemp = overrideTemp; } + @Override + protected Path getTempSubFolderPath(@SuppressWarnings("NullableProblems") PsiFile file) { + return overrideTemp; + } + @Override + protected void deleteTempFolder(Path tempFolder) { /* no-op for test visibility */ } + } + + private PsiFile mockPsiFile(String name) { + PsiFile psi = mock(PsiFile.class, RETURNS_DEEP_STUBS); + when(psi.getName()).thenReturn(name); + return psi; + } + + @Test @DisplayName("shouldScanFile_nonManifestPath_returnsFalse") + void testShouldScanFile_nonManifestPath_returnsFalse() { + assertFalse(new OssScannerService().shouldScanFile("/project/README.md")); + } + @Test @DisplayName("shouldScanFile_nodeModulesPath_returnsFalse") + void testShouldScanFile_nodeModulesPath_returnsFalse() { + assertFalse(new OssScannerService().shouldScanFile("/project/node_modules/package.json")); + } + @Test @DisplayName("shouldScanFile_manifestPath_returnsTrue") + void testShouldScanFile_manifestPath_returnsTrue() { + assertTrue(new OssScannerService().shouldScanFile("/project/package.json")); + } + + @Test @DisplayName("scan_shouldScanFileFalse_returnsNull") + void testScan_shouldScanFileFalse_returnsNull() { + OssScannerService service = spy(new OssScannerService()); + doReturn(false).when(service).shouldScanFile(anyString()); + assertNull(service.scan(mockPsiFile("package.json"), "/project/package.json")); + } + + @Test @DisplayName("scan_blankContent_returnsNull") + void testScan_blankContent_returnsNull() { + OssScannerService service = spy(new OssScannerService()); + PsiFile psi = mockPsiFile("package.json"); + doReturn(true).when(service).shouldScanFile(anyString()); + try(MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.getFileContent(psi)).thenReturn(" "); + assertNull(service.scan(psi, "/project/package.json")); + } + } + + @Test @DisplayName("scan_nullContent_returnsNull") + void testScan_nullContent_returnsNull() { + OssScannerService service = spy(new OssScannerService()); + PsiFile psi = mockPsiFile("package.json"); + doReturn(true).when(service).shouldScanFile(anyString()); + try(MockedStatic utils = mockStatic(DevAssistUtils.class)) { + utils.when(() -> DevAssistUtils.getFileContent(psi)).thenReturn(null); + assertNull(service.scan(psi, "/project/package.json")); + } + } + + @Test @DisplayName("scan_validContent_noPackages_returnsAdaptorWithEmptyIssues") + void testScan_validContent_noPackages_returnsAdaptorWithEmptyIssues() throws Exception { + Path temp = Files.createTempDirectory("oss-no-packages"); + OssScannerService service = spy(new TestableOssScannerService(temp)); + PsiFile psi = mockPsiFile("package.json"); + doReturn(true).when(service).shouldScanFile(anyString()); + try (MockedStatic utils = mockStatic(DevAssistUtils.class); + MockedStatic factory = mockStatic(CxWrapperFactory.class)) { + utils.when(() -> DevAssistUtils.getFileContent(psi)).thenReturn("{ }\n"); + CxWrapper wrapper = mock(CxWrapper.class); + OssRealtimeResults realtimeResults = mock(OssRealtimeResults.class); + when(realtimeResults.getPackages()).thenReturn(List.of()); + when(wrapper.ossRealtimeScan(anyString(), anyString())).thenReturn(realtimeResults); + factory.when(CxWrapperFactory::build).thenReturn(wrapper); + com.checkmarx.intellij.devassist.common.ScanResult result = service.scan(psi, temp.resolve("package.json").toString()); + assertNotNull(result); + assertTrue(result.getIssues().isEmpty()); + } + } + + @Test @DisplayName("scan_validContent_withIssues_mapsVulnsAndLocations") + void testScan_validContent_withIssues_mapsVulnsAndLocations() throws Exception { + Path temp = Files.createTempDirectory("oss-with-packages"); + OssScannerService service = spy(new TestableOssScannerService(temp)); + PsiFile psi = mockPsiFile("package.json"); + doReturn(true).when(service).shouldScanFile(anyString()); + try (MockedStatic utils = mockStatic(DevAssistUtils.class); + MockedStatic factory = mockStatic(CxWrapperFactory.class)) { + utils.when(() -> DevAssistUtils.getFileContent(psi)).thenReturn("{ }\n"); + CxWrapper wrapper = mock(CxWrapper.class); + OssRealtimeResults realtimeResults = mock(OssRealtimeResults.class); + OssRealtimeScanPackage pkg = mock(OssRealtimeScanPackage.class); + OssRealtimeVulnerability vul = mock(OssRealtimeVulnerability.class); + RealtimeLocation loc = mock(RealtimeLocation.class); + when(loc.getLine()).thenReturn(4); // zero-based line 4 -> stored +1 in Location + when(loc.getStartIndex()).thenReturn(1); + when(loc.getEndIndex()).thenReturn(3); + when(vul.getCve()).thenReturn("CVE-123"); + when(vul.getDescription()).thenReturn("Desc"); + when(vul.getSeverity()).thenReturn("HIGH"); + when(vul.getFixVersion()).thenReturn("2.0.0"); + when(pkg.getPackageName()).thenReturn("mypkg"); + when(pkg.getPackageVersion()).thenReturn("1.0.0"); + when(pkg.getStatus()).thenReturn("CRITICAL"); + when(pkg.getVulnerabilities()).thenReturn(List.of(vul)); + when(pkg.getLocations()).thenReturn(List.of(loc)); + when(realtimeResults.getPackages()).thenReturn(List.of(pkg)); + when(wrapper.ossRealtimeScan(anyString(), anyString())).thenReturn(realtimeResults); + factory.when(CxWrapperFactory::build).thenReturn(wrapper); + var result = service.scan(psi, temp.resolve("package.json").toString()); + assertNotNull(result); + assertEquals(1, result.getIssues().size()); + var issue = result.getIssues().get(0); + assertEquals("mypkg", issue.getTitle()); + assertEquals("1.0.0", issue.getPackageVersion()); + assertEquals(ScanEngine.OSS, issue.getScanEngine()); + assertEquals("CRITICAL", issue.getSeverity()); + assertEquals(1, issue.getVulnerabilities().size()); + assertEquals("CVE-123", issue.getVulnerabilities().get(0).getCve()); + assertEquals(1, issue.getLocations().size()); + assertEquals(5, issue.getLocations().get(0).getLine()); // +1 applied + } + } + @Test @DisplayName("scan_validContent_withCompanionFile_copiesLockFile") + void testScan_validContent_withCompanionFile_copiesLockFile() throws Exception { + Path parent = Files.createTempDirectory("oss-companion"); + // create companion source file + Files.writeString(parent.resolve("package-lock.json"), "lock"); + Path forcedTemp = Files.createTempDirectory("oss-companion-target"); + OssScannerService service = spy(new NoDeleteOssScannerService(forcedTemp)); + PsiFile psi = mockPsiFile("package.json"); + doReturn(true).when(service).shouldScanFile(anyString()); + try (MockedStatic utils = mockStatic(DevAssistUtils.class); + MockedStatic factory = mockStatic(CxWrapperFactory.class)) { + utils.when(() -> DevAssistUtils.getFileContent(psi)).thenReturn("{ }\n"); + CxWrapper wrapper = mock(CxWrapper.class); + OssRealtimeResults realtimeResults = mock(OssRealtimeResults.class); + when(realtimeResults.getPackages()).thenReturn(List.of()); + when(wrapper.ossRealtimeScan(anyString(), anyString())).thenReturn(realtimeResults); + factory.when(CxWrapperFactory::build).thenReturn(wrapper); + service.scan(psi, parent.resolve("package.json").toString()); + assertTrue(Files.exists(forcedTemp.resolve("package-lock.json")), "Companion lock file should be copied to temp folder"); + } + } + + @Test @DisplayName("scan_wrapperThrowsIOException_returnsNull") + void testScan_wrapperThrowsIOException_returnsNull() throws Exception { + Path temp = Files.createTempDirectory("oss-ioe"); + OssScannerService service = spy(new TestableOssScannerService(temp)); + PsiFile psi = mockPsiFile("package.json"); + doReturn(true).when(service).shouldScanFile(anyString()); + try (MockedStatic utils = mockStatic(DevAssistUtils.class); + MockedStatic factory = mockStatic(CxWrapperFactory.class)) { + utils.when(() -> DevAssistUtils.getFileContent(psi)).thenReturn("{ }\n"); + factory.when(CxWrapperFactory::build).thenThrow(new IOException("simulated")); + assertNull(service.scan(psi, temp.resolve("package.json").toString())); + } + } + + @Test @DisplayName("createConfig_engineNameIsOSS") + void testCreateConfig_engineNameIsOSS() { + assertEquals(ScanEngine.OSS.name(), OssScannerService.createConfig().getEngineName()); + } + + @Test @DisplayName("getIssues_nullResults_returnsEmptyList") + void testGetIssues_nullResults_returnsEmptyList() { + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(null); + assertTrue(adaptor.getIssues().isEmpty()); + } + + @Test @DisplayName("getIssues_emptyPackages_returnsEmptyList") + void testGetIssues_emptyPackages_returnsEmptyList() { + OssRealtimeResults results = mock(OssRealtimeResults.class); + when(results.getPackages()).thenReturn(List.of()); + OssScanResultAdaptor adaptor = new OssScanResultAdaptor(results); + assertTrue(adaptor.getIssues().isEmpty()); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/utils/DevAssistUtilsTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/utils/DevAssistUtilsTest.java new file mode 100644 index 00000000..2ac2eab9 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/utils/DevAssistUtilsTest.java @@ -0,0 +1,266 @@ +package com.checkmarx.intellij.unit.devassist.utils; + +import com.checkmarx.intellij.devassist.configuration.GlobalScannerController; +import com.checkmarx.intellij.devassist.utils.DevAssistUtils; +import com.checkmarx.intellij.devassist.utils.ScanEngine; +import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.checkmarx.intellij.util.SeverityLevel; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import com.intellij.util.ui.UIUtil; +import com.intellij.openapi.util.Computable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class DevAssistUtilsTest { + + // Helper to mock Document with desired line content + private Document mockDocument(String[] lines) { + Document doc = mock(Document.class); + when(doc.getLineCount()).thenReturn(lines.length); + // Build a single joined text with newlines + StringBuilder all = new StringBuilder(); + for (int i=0;i stateMock = mockStatic(GlobalSettingsState.class)) { + GlobalSettingsState state = mock(GlobalSettingsState.class); + when(state.isAuthenticated()).thenReturn(true); + stateMock.when(GlobalSettingsState::getInstance).thenReturn(state); + assertFalse(DevAssistUtils.isScannerActive("not_an_engine")); + } + } + + @Test @DisplayName("isScannerActive_authenticatedEnabledScanner_returnsTrue") + void testIsScannerActive_authenticatedEnabledScanner_returnsTrue() { + try (MockedStatic stateMock = mockStatic(GlobalSettingsState.class); + MockedStatic self = mockStatic(DevAssistUtils.class, CALLS_REAL_METHODS)) { + GlobalSettingsState state = mock(GlobalSettingsState.class); + when(state.isAuthenticated()).thenReturn(true); + stateMock.when(GlobalSettingsState::getInstance).thenReturn(state); + GlobalScannerController ctrl = mock(GlobalScannerController.class); + when(ctrl.isScannerGloballyEnabled(ScanEngine.OSS)).thenReturn(true); + self.when(DevAssistUtils::globalScannerController).thenReturn(ctrl); + assertTrue(DevAssistUtils.isScannerActive("oss")); + } + } + + @Test @DisplayName("isScannerActive_authenticatedDisabledScanner_returnsFalse") + void testIsScannerActive_authenticatedDisabledScanner_returnsFalse() { + try (MockedStatic stateMock = mockStatic(GlobalSettingsState.class); + MockedStatic self = mockStatic(DevAssistUtils.class, CALLS_REAL_METHODS)) { + GlobalSettingsState state = mock(GlobalSettingsState.class); + when(state.isAuthenticated()).thenReturn(true); + stateMock.when(GlobalSettingsState::getInstance).thenReturn(state); + GlobalScannerController ctrl = mock(GlobalScannerController.class); + when(ctrl.isScannerGloballyEnabled(ScanEngine.OSS)).thenReturn(false); + self.when(DevAssistUtils::globalScannerController).thenReturn(ctrl); + assertFalse(DevAssistUtils.isScannerActive("oss")); + } + } + + @Test @DisplayName("isAnyScannerEnabled_controllerReturnsTrue") + void testIsAnyScannerEnabled_controllerReturnsTrue() { + try (MockedStatic self = mockStatic(DevAssistUtils.class, CALLS_REAL_METHODS)) { + GlobalScannerController ctrl = mock(GlobalScannerController.class); + when(ctrl.checkAnyScannerEnabled()).thenReturn(true); + self.when(DevAssistUtils::globalScannerController).thenReturn(ctrl); + assertTrue(DevAssistUtils.isAnyScannerEnabled()); + } + } + + @Test @DisplayName("isAnyScannerEnabled_controllerReturnsFalse") + void testIsAnyScannerEnabled_controllerReturnsFalse() { + try (MockedStatic self = mockStatic(DevAssistUtils.class, CALLS_REAL_METHODS)) { + GlobalScannerController ctrl = mock(GlobalScannerController.class); + when(ctrl.checkAnyScannerEnabled()).thenReturn(false); + self.when(DevAssistUtils::globalScannerController).thenReturn(ctrl); + assertFalse(DevAssistUtils.isAnyScannerEnabled()); + } + } + + // getTextRangeForLine tests + @Test @DisplayName("getTextRangeForLine_allWhitespaceLine_returnsFullRange") + void testGetTextRangeForLine_allWhitespaceLine_returnsFullRange() { + Document doc = mockDocument(new String[]{" ","code"}); + TextRange range = DevAssistUtils.getTextRangeForLine(doc,1); // first line (1-based) + assertEquals(doc.getLineStartOffset(0), range.getStartOffset()); + assertEquals(doc.getLineEndOffset(0), range.getEndOffset()); + } + + @Test @DisplayName("getTextRangeForLine_trimmedLine_correctOffsets") + void testGetTextRangeForLine_trimmedLine_correctOffsets() { + Document doc = mockDocument(new String[]{" hello ","other"}); + TextRange range = DevAssistUtils.getTextRangeForLine(doc,1); + CharSequence all = doc.getCharsSequence(); + int start = all.toString().indexOf("hello"); + int end = start + "hello".length(); + assertEquals(start, range.getStartOffset()); + assertEquals(end, range.getEndOffset()); + } + + // isLineOutOfRange tests + @Test @DisplayName("isLineOutOfRange_zero_returnsTrue") + void testIsLineOutOfRange_zero_returnsTrue() { + Document doc = mockDocument(new String[]{"a"}); + assertTrue(DevAssistUtils.isLineOutOfRange(0, doc)); + } + + @Test @DisplayName("isLineOutOfRange_gtCount_returnsTrue") + void testIsLineOutOfRange_gtCount_returnsTrue() { + Document doc = mockDocument(new String[]{"a","b"}); + assertTrue(DevAssistUtils.isLineOutOfRange(3, doc)); + } + + @Test @DisplayName("isLineOutOfRange_valid_returnsFalse") + void testIsLineOutOfRange_valid_returnsFalse() { + Document doc = mockDocument(new String[]{"a","b"}); + assertFalse(DevAssistUtils.isLineOutOfRange(2, doc)); + } + + // wrapTextAtWord tests + @Test @DisplayName("wrapTextAtWord_shortText_noWrap") + void testWrapTextAtWord_shortText_noWrap() { + assertEquals("hello", DevAssistUtils.wrapTextAtWord("hello",10)); + } + + @Test @DisplayName("wrapTextAtWord_wordExceedsMax_startsNewLine") + void testWrapTextAtWord_wordExceedsMax_startsNewLine() { + String wrapped = DevAssistUtils.wrapTextAtWord("abc defghijkl",5); + assertTrue(wrapped.contains("\ndefghijkl")); + } + + @Test @DisplayName("wrapTextAtWord_multipleWraps_correctBreaks") + void testWrapTextAtWord_multipleWraps_correctBreaks() { + String wrapped = DevAssistUtils.wrapTextAtWord("one two three four",7); + // Expect line breaks before words causing overflow + assertTrue(wrapped.contains("one two")); + assertTrue(wrapped.contains("three")); + } + + // isProblem tests + @Test @DisplayName("isProblem_severityOK_returnsFalse") + void testIsProblem_severityOK_returnsFalse() { + assertFalse(DevAssistUtils.isProblem(SeverityLevel.OK.getSeverity())); + } + + @Test @DisplayName("isProblem_severityUNKNOWN_returnsFalse") + void testIsProblem_severityUNKNOWN_returnsFalse() { + assertFalse(DevAssistUtils.isProblem(SeverityLevel.UNKNOWN.getSeverity())); + } + + @Test @DisplayName("isProblem_severityHigh_returnsTrue") + void testIsProblem_severityHigh_returnsTrue() { + assertTrue(DevAssistUtils.isProblem("HIGH")); + } + + // themeBasedPNGIconForHtmlImage tests + @Test @DisplayName("themeBasedPNGIconForHtmlImage_nullInput_returnsEmpty") + void testThemeBasedPNGIconForHtmlImage_nullInput_returnsEmpty() { + assertEquals("", DevAssistUtils.themeBasedPNGIconForHtmlImage(null)); + } + + @Test @DisplayName("themeBasedPNGIconForHtmlImage_emptyInput_returnsEmpty") + void testThemeBasedPNGIconForHtmlImage_emptyInput_returnsEmpty() { + assertEquals("", DevAssistUtils.themeBasedPNGIconForHtmlImage("")); + } + + @Test @DisplayName("themeBasedPNGIconForHtmlImage_nonExisting_returnsEmpty") + void testThemeBasedPNGIconForHtmlImage_nonExisting_returnsEmpty() { + assertEquals("", DevAssistUtils.themeBasedPNGIconForHtmlImage("/icons/does_not_exist")); + } + + // isDarkTheme test (mock UIUtil) + @Test @DisplayName("isDarkTheme_darculaTrue_returnsTrue") + void testIsDarkTheme_darculaTrue_returnsTrue() { + try (MockedStatic ui = mockStatic(UIUtil.class)) { + ui.when(UIUtil::isUnderDarcula).thenReturn(true); + assertTrue(DevAssistUtils.isDarkTheme()); + } + } + + @Test @DisplayName("isDarkTheme_darculaFalse_returnsFalse") + void testIsDarkTheme_darculaFalse_returnsFalse() { + try (MockedStatic ui = mockStatic(UIUtil.class)) { + ui.when(UIUtil::isUnderDarcula).thenReturn(false); + assertFalse(DevAssistUtils.isDarkTheme()); + } + } + + // getFileContent tests: simulate document path + @Test @DisplayName("getFileContent_documentPresent_returnsText") + void testGetFileContent_documentPresent_returnsText() { + PsiFile psi = mock(PsiFile.class, RETURNS_DEEP_STUBS); + Project mockProject = mock(Project.class); + when(psi.getProject()).thenReturn(mockProject); + try (MockedStatic app = mockStatic(ApplicationManager.class, CALLS_REAL_METHODS)) { + var application = mock(com.intellij.openapi.application.Application.class); + app.when(ApplicationManager::getApplication).thenReturn(application); + doAnswer(inv -> { + Object arg = inv.getArgument(0); + if (arg instanceof com.intellij.openapi.util.Computable) { + com.intellij.openapi.util.Computable comp = (com.intellij.openapi.util.Computable) arg; + Document doc = mock(Document.class); + when(doc.getText()).thenReturn("content"); + var psiDocMgr = mock(com.intellij.psi.PsiDocumentManager.class); + when(psiDocMgr.getDocument(psi)).thenReturn(doc); + try (MockedStatic mgr = mockStatic(com.intellij.psi.PsiDocumentManager.class)) { + mgr.when(() -> com.intellij.psi.PsiDocumentManager.getInstance(mockProject)).thenReturn(psiDocMgr); + return comp.compute(); + } + } + return null; + }).when(application).runReadAction(any(Computable.class)); + assertEquals("content", DevAssistUtils.getFileContent(psi)); + } + } + + @Test @DisplayName("getFileContent_noDocumentVirtualFileNull_returnsNull") + void testGetFileContent_noDocumentVirtualFileNull_returnsNull() { + PsiFile psi = mock(PsiFile.class, RETURNS_DEEP_STUBS); + Project mockProject = mock(Project.class); + when(psi.getProject()).thenReturn(mockProject); + when(psi.getVirtualFile()).thenReturn(null); + try (MockedStatic app = mockStatic(ApplicationManager.class, CALLS_REAL_METHODS)) { + var application = mock(com.intellij.openapi.application.Application.class); + app.when(ApplicationManager::getApplication).thenReturn(application); + doAnswer(inv -> { + Object arg = inv.getArgument(0); + if (arg instanceof com.intellij.openapi.util.Computable) { + com.intellij.openapi.util.Computable comp = (com.intellij.openapi.util.Computable) arg; + var psiDocMgr = mock(com.intellij.psi.PsiDocumentManager.class); + when(psiDocMgr.getDocument(psi)).thenReturn(null); + try (MockedStatic mgr = mockStatic(com.intellij.psi.PsiDocumentManager.class)) { + mgr.when(() -> com.intellij.psi.PsiDocumentManager.getInstance(mockProject)).thenReturn(psiDocMgr); + return comp.compute(); + } + } + return null; + }).when(application).runReadAction(any(Computable.class)); + assertNull(DevAssistUtils.getFileContent(psi)); + } + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/devassist/welcomedialog/WelcomeDialogTest.java b/src/test/java/com/checkmarx/intellij/unit/devassist/welcomedialog/WelcomeDialogTest.java new file mode 100644 index 00000000..08eed6b7 --- /dev/null +++ b/src/test/java/com/checkmarx/intellij/unit/devassist/welcomedialog/WelcomeDialogTest.java @@ -0,0 +1,179 @@ +package com.checkmarx.intellij.unit.devassist.welcomedialog; + +import com.checkmarx.intellij.Resource; +import com.checkmarx.intellij.devassist.ui.WelcomeDialog; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.*; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import com.intellij.ui.components.JBCheckBox; + +import static org.junit.jupiter.api.Assertions.*; + +public class WelcomeDialogTest { + + static class FakeSettings implements WelcomeDialog.RealTimeSettingsManager { + boolean all; + @Override public boolean areAllEnabled() { return all; } + @Override public void setAll(boolean enable) { this.all = enable; } + } + + private WelcomeDialog newDialogBypassCtor(boolean mcpEnabled, WelcomeDialog.RealTimeSettingsManager mgr) throws Exception { + var unsafeField = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + sun.misc.Unsafe unsafe = (sun.misc.Unsafe) unsafeField.get(null); + WelcomeDialog dlg = (WelcomeDialog) unsafe.allocateInstance(WelcomeDialog.class); + // Set required fields via reflection + setField(dlg, "mcpEnabled", mcpEnabled); + setField(dlg, "settingsManager", mgr); + // Prepare checkbox field as done by createFeatureCardHeader + JBCheckBox check = new JBCheckBox(); + check.setEnabled(mcpEnabled); + setField(dlg, "realTimeScannersCheckbox", check); + return dlg; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + private Object invokeProtected(Object target, String name, Class[] types, Object... args) throws Exception { + Method m = target.getClass().getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(target, args); + } + + @Test + @DisplayName("createBullet wraps text and returns panel with glyph and label") + void testCreateBullet_WrapsAndReturnsPanel() throws Exception { + WelcomeDialog dlg = newDialogBypassCtor(false, new FakeSettings()); + JComponent bullet = dlg.createBullet(Resource.WELCOME_MAIN_FEATURE_1); + assertNotNull(bullet); + JPanel bulletPanel = assertInstanceOf(JPanel.class, bullet); + assertEquals(2, bulletPanel.getComponentCount()); + } + + @Test + @DisplayName("Checkbox disabled when MCP is not enabled and tooltip indicates MCP not enabled") + void testCheckbox_McpDisabled_TooltipMessage() throws Exception { + FakeSettings settings = new FakeSettings(); + WelcomeDialog dlg = newDialogBypassCtor(false, settings); + invokeProtected(dlg, "refreshCheckboxState", new Class[]{}); + JCheckBox box = dlg.getRealTimeScannersCheckbox(); + assertNotNull(box); + assertFalse(box.isEnabled()); + assertFalse(box.isSelected()); + assertEquals("Checkmarx MCP is not enabled for this tenant.", box.getToolTipText()); + } + + @Test + @DisplayName("Checkbox action toggles settings and updates selection state when enabled") + void testCheckbox_Action_TogglesSettingsAndSelection() throws Exception { + FakeSettings settings = new FakeSettings(); + WelcomeDialog dlg = newDialogBypassCtor(true, settings); + invokeProtected(dlg, "configureCheckboxBehavior", new Class[]{}); + JCheckBox box = dlg.getRealTimeScannersCheckbox(); + assertNotNull(box); + assertTrue(box.isEnabled()); + assertFalse(box.isSelected()); + box.doClick(); + assertTrue(settings.areAllEnabled()); + invokeProtected(dlg, "refreshCheckboxState", new Class[]{}); + assertEquals(settings.areAllEnabled(), box.isSelected()); + assertEquals("Disable all real-time scanners", box.getToolTipText()); + } + + @Test + @DisplayName("Feature card header initializes checkbox enabled state based on MCP") + void testCreateFeatureCardHeader_CheckboxEnabledByMcp() throws Exception { + WelcomeDialog dlgEnabled = newDialogBypassCtor(true, new FakeSettings()); + JPanel headerEnabled = (JPanel) invokeProtected(dlgEnabled, "createFeatureCardHeader", new Class[]{Color.class}, Color.GRAY); + assertNotNull(headerEnabled); + JCheckBox boxEnabled = dlgEnabled.getRealTimeScannersCheckbox(); + assertTrue(boxEnabled.isEnabled()); + + WelcomeDialog dlgDisabled = newDialogBypassCtor(false, new FakeSettings()); + JPanel headerDisabled = (JPanel) invokeProtected(dlgDisabled, "createFeatureCardHeader", new Class[]{Color.class}, Color.GRAY); + assertNotNull(headerDisabled); + JCheckBox boxDisabled = dlgDisabled.getRealTimeScannersCheckbox(); + assertFalse(boxDisabled.isEnabled()); + } + + @Test + @DisplayName("Feature card bullets include MCP info when enabled, icon when disabled") + void testCreateFeatureCardBullets_McpBranches() throws Exception { + WelcomeDialog dlgEnabled = newDialogBypassCtor(true, new FakeSettings()); + JPanel bulletsEnabled = (JPanel) invokeProtected(dlgEnabled, "createFeatureCardBullets", new Class[]{}); + assertNotNull(bulletsEnabled); + assertTrue(bulletsEnabled.getComponentCount() >= 4); // includes MCP installed info bullet + + WelcomeDialog dlgDisabled = newDialogBypassCtor(false, new FakeSettings()); + JPanel bulletsDisabled = (JPanel) invokeProtected(dlgDisabled, "createFeatureCardBullets", new Class[]{}); + assertNotNull(bulletsDisabled); + assertTrue(bulletsDisabled.getComponentCount() >= 4); // last is icon label when MCP disabled + Component last = bulletsDisabled.getComponent(bulletsDisabled.getComponentCount() - 1); + assertInstanceOf(JLabel.class, last); + } + + @Test + @DisplayName("Right image panel creates fixed-size panel with image label") + void testCreateRightImagePanel_PanelAndImage() throws Exception { + WelcomeDialog dlg = newDialogBypassCtor(false, new FakeSettings()); + JPanel right = (JPanel) invokeProtected(dlg, "createRightImagePanel", new Class[]{}); + assertNotNull(right); + assertTrue(right.getComponentCount() >= 1); + Component c = right.getComponent(0); + assertInstanceOf(JLabel.class, c); + } + + @Test + @DisplayName("updateCheckboxTooltip shows enable/disable messages when MCP enabled") + void testUpdateCheckboxTooltip_EnableDisableMessages() throws Exception { + WelcomeDialog dlg = newDialogBypassCtor(true, new FakeSettings()); + JCheckBox box = dlg.getRealTimeScannersCheckbox(); + box.setSelected(false); + invokeProtected(dlg, "updateCheckboxTooltip", new Class[]{}); + assertEquals("Enable all real-time scanners", box.getToolTipText()); + box.setSelected(true); + invokeProtected(dlg, "updateCheckboxTooltip", new Class[]{}); + assertEquals("Disable all real-time scanners", box.getToolTipText()); + } + + @Test + @DisplayName("updateCheckboxTooltip shows MCP not enabled when MCP disabled") + void testUpdateCheckboxTooltip_McpDisabledMessage() throws Exception { + WelcomeDialog dlg = newDialogBypassCtor(false, new FakeSettings()); + JCheckBox box = dlg.getRealTimeScannersCheckbox(); + box.setSelected(true); + invokeProtected(dlg, "updateCheckboxTooltip", new Class[]{}); + assertEquals("Checkmarx MCP is not enabled for this tenant.", box.getToolTipText()); + } + + @Test + @DisplayName("createFeatureCard builds header + bullets") + void testCreateFeatureCard_Composition() throws Exception { + WelcomeDialog dlg = newDialogBypassCtor(false, new FakeSettings()); + JPanel featureCard = (JPanel) invokeProtected(dlg, "createFeatureCard", new Class[]{}); + assertNotNull(featureCard); + assertTrue(featureCard.getComponentCount() >= 2); // header + bullets + } + + static class TestSubclass extends WelcomeDialog { + TestSubclass(boolean mcp, RealTimeSettingsManager mgr) throws Exception { super(null, mcp, mgr); } + public JComponent exposedCenter() { return createCenterPanel(); } + } + + @Test + @DisplayName("createCenterPanel returns panel with left and right child when MCP disabled") + void testCreateCenterPanel_McpDisabled() throws Exception { + WelcomeDialog dlg = newDialogBypassCtor(false, new FakeSettings()); + JPanel center = (JPanel) invokeProtected(dlg, "createCenterPanel", new Class[]{}); + assertNotNull(center); + assertTrue(center.getComponentCount() >= 2); + } +} diff --git a/src/test/java/com/checkmarx/intellij/unit/inspections/AscaInspectionTest.java b/src/test/java/com/checkmarx/intellij/unit/inspections/AscaInspectionTest.java index 6012a46f..babd9395 100644 --- a/src/test/java/com/checkmarx/intellij/unit/inspections/AscaInspectionTest.java +++ b/src/test/java/com/checkmarx/intellij/unit/inspections/AscaInspectionTest.java @@ -6,6 +6,7 @@ import com.checkmarx.intellij.Constants; import com.checkmarx.intellij.inspections.AscaInspection; import com.checkmarx.intellij.settings.global.GlobalSettingsState; +import com.checkmarx.intellij.util.SeverityLevel; import com.intellij.codeInspection.InspectionManager; import com.intellij.codeInspection.ProblemDescriptor; import com.intellij.codeInspection.ProblemHighlightType; @@ -123,7 +124,7 @@ void checkFile_WithValidScanResult_CreatesProblemDescriptors() { when(mockDetail.getLine()).thenReturn(1); when(mockDetail.getRuleName()).thenReturn("Test Rule"); when(mockDetail.getRemediationAdvise()).thenReturn("Fix this"); - when(mockDetail.getSeverity()).thenReturn(Constants.ASCA_HIGH_SEVERITY); + when(mockDetail.getSeverity()).thenReturn(SeverityLevel.HIGH.getSeverity()); try (MockedStatic docManagerMock = mockStatic(PsiDocumentManager.class)) { when(mockSettings.isAsca()).thenReturn(true);