From 99a4b2f1510b329d75b26a92b0ffde7273086d15 Mon Sep 17 00:00:00 2001 From: Casey Marshall Date: Tue, 23 Jan 2024 13:56:40 -0600 Subject: [PATCH] feat: snyk controller extension point Add an extension point to allow third-party plugins to interact with Snyk in the IDE. --- CHANGELOG.md | 6 +- EXTENSIONS.md | 102 ++++++++++++++++++ build.gradle.kts | 18 ++++ .../io/snyk/plugin/SnykPostStartupActivity.kt | 16 +++ .../snyk/plugin/extensions/SnykController.kt | 19 ++++ .../plugin/extensions/SnykControllerImpl.kt | 27 +++++ .../extensions/SnykControllerManager.kt | 13 +++ src/main/resources/META-INF/plugin.xml | 6 ++ .../extensions/SnykControllerImplTest.kt | 99 +++++++++++++++++ 9 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 EXTENSIONS.md create mode 100644 src/main/kotlin/io/snyk/plugin/extensions/SnykController.kt create mode 100644 src/main/kotlin/io/snyk/plugin/extensions/SnykControllerImpl.kt create mode 100644 src/main/kotlin/io/snyk/plugin/extensions/SnykControllerManager.kt create mode 100644 src/test/kotlin/io/snyk/plugin/extensions/SnykControllerImplTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4202712a2..f8f90753e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Snyk Changelog -## [2.6.1] +## [2.7.0] +### Added +- Snyk controller extension point + +## [2.6.2] ### Fixed - remove more intellij-specific code and configuration diff --git a/EXTENSIONS.md b/EXTENSIONS.md new file mode 100644 index 000000000..4ff847e9c --- /dev/null +++ b/EXTENSIONS.md @@ -0,0 +1,102 @@ +# Snyk Extensions + +This plugin offers an extension point for integrating Snyk with other Jetbrains IDE Plugins. + +## What is the Snyk Controller? + +The Snyk Controller is, put simply, a way to control Snyk from other plugins. + +## Why would I use the Snyk Controller extension? + +There are a few of use cases for the Snyk controller: + +### Initiating a Snyk scan + +There may be situations in which a plugin wants to initiate a security scan, especially at the end of a workflow which +introduces changes to the project source code, manifest dependencies, OCI container builds, infrastructure files, +etc. -- anything Snyk can scan. + +### Determining whether Snyk is authenticated to a user + +It might be useful to know whether the user has authenticated with Snyk; is Snyk ready to run scans? + +## How do I use the extension in my plugin? + +### Add a dependency on this plugin to your plugin's `plugin.xml` file. + +Release >= 2.7.0 provides the extension point. + +```xml +io.snyk.snyk-intellij-plugin +``` + +### Declare the extension + +Add an extension for `io.snyk.snyk-intellij-plugin.controllerManager` to your plugin's `plugin.xml` file. + +```xml + + + +``` + +### Optional dependency + +The dependency on Snyk can also be optional: + +```xml +io.snyk.snyk-intellij-plugin +``` + +Declare the controller extension in the dependency's config file, located relative to the `plugin.xml`: + +```xml + + + + + + +``` + +### Implement the controller manager interface + +The manager will be instantiated and passed an instance of the controller when the Snyk plugin is initialized. + +```kotlin +package com.example.demointellij + +import io.snyk.plugin.extensions.SnykController +import io.snyk.plugin.extensions.SnykControllerManager + +class HelloWorldControllerManager : SnykControllerManager { + override fun register(controller: SnykController) { + Utils().getSnykControllerService().setController(controller) + } +} +``` + +Snyk recommends building a [service](https://plugins.jetbrains.com/docs/intellij/plugin-services.html) to receive the +controller instance and provide it to other parts of your plugin. + +### Use the controller in your plugin + +See [SnykController](https://github.com/snyk/snyk-intellij-plugin/blob/main/src/main/kotlin/io/snyk/plugin/extensions/SnykController.kt) +for the current Snyk methods supported. + +#### Initiating a scan + +```kotlin +Utils().getSnykControllerService().getController()?.scan() +``` + +## What compatibility guarantees are there, for consumers of this extension? + +Our [semantic version](https://semver.org/) releases indicate the compatibility of the extension API with respect to +past releases. + +With a minor version increment, new methods may be added to interfaces, but existing methods will not be removed or +their prototypes changed. + +With a major version increment, there are no compatibility guarantees. Snyk suggests checking the release notes, source +changes, and compatibility testing before upgrading. diff --git a/build.gradle.kts b/build.gradle.kts index d4bfdf8a2..d26e9714f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -133,6 +133,24 @@ tasks { } } + val createOpenApiSourceJar by registering(Jar::class) { + // Java sources + from(sourceSets.main.get().java) { + include("**/*.java") + } + // Kotlin sources + from(kotlin.sourceSets.main.get().kotlin) { + include("**/*.kt") + } + destinationDirectory.set(layout.buildDirectory.dir("libs")) + archiveClassifier.set("src") + } + + buildPlugin { + dependsOn(createOpenApiSourceJar) + from(createOpenApiSourceJar) { into("lib/src") } + } + buildSearchableOptions { enabled = false } diff --git a/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt b/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt index 7f136add0..3452716d2 100644 --- a/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt +++ b/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt @@ -5,10 +5,13 @@ import com.intellij.ide.plugins.PluginInstaller import com.intellij.ide.plugins.PluginStateListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.vfs.VirtualFileManager +import io.snyk.plugin.extensions.SnykControllerImpl +import io.snyk.plugin.extensions.SnykControllerManager import io.snyk.plugin.snykcode.SnykCodeBulkFileListener import io.snyk.plugin.snykcode.core.AnalysisData import io.snyk.plugin.snykcode.core.SnykCodeIgnoreInfoHolder @@ -27,8 +30,17 @@ import java.util.* private val LOG = logger() +private const val EXTENSION_POINT_CONTROLLER_MANAGER = "io.snyk.snyk-intellij-plugin.controllerManager" + class SnykPostStartupActivity : ProjectActivity { + private object ExtensionPointsUtil { + val controllerManager = + ExtensionPointName.create( + EXTENSION_POINT_CONTROLLER_MANAGER + ) + } + private var listenersActivated = false val settings = pluginSettings() @@ -95,6 +107,10 @@ class SnykPostStartupActivity : ProjectActivity { if (isContainerEnabled()) { getKubernetesImageCache(project)?.scanProjectForKubernetesFiles() } + + ExtensionPointsUtil.controllerManager.extensionList.forEach { + it.register(SnykControllerImpl(project)) + } } } diff --git a/src/main/kotlin/io/snyk/plugin/extensions/SnykController.kt b/src/main/kotlin/io/snyk/plugin/extensions/SnykController.kt new file mode 100644 index 000000000..dcad7de47 --- /dev/null +++ b/src/main/kotlin/io/snyk/plugin/extensions/SnykController.kt @@ -0,0 +1,19 @@ +package io.snyk.plugin.extensions + +/** + * SnykController is used by third-party plugins to interact with the Snyk plugin. + */ +interface SnykController { + + /** + * scan enqueues a scan of the project for vulnerabilities. + */ + fun scan() + + /** + * userId returns the current authenticated Snyk user's ID. + * + * If no user is authenticated, this will return null. + */ + fun userId(): String? +} diff --git a/src/main/kotlin/io/snyk/plugin/extensions/SnykControllerImpl.kt b/src/main/kotlin/io/snyk/plugin/extensions/SnykControllerImpl.kt new file mode 100644 index 000000000..3365b1611 --- /dev/null +++ b/src/main/kotlin/io/snyk/plugin/extensions/SnykControllerImpl.kt @@ -0,0 +1,27 @@ +package io.snyk.plugin.extensions + +import com.intellij.openapi.project.Project +import io.snyk.plugin.getSnykApiService +import io.snyk.plugin.getSnykTaskQueueService + +/** + * SnykController is used by third-party plugins to interact with the Snyk plugin. + */ +class SnykControllerImpl(val project: Project) : SnykController { + + /** + * scan enqueues a scan of the project for vulnerabilities. + */ + override fun scan() { + getSnykTaskQueueService(project)?.scan() + } + + /** + * userId returns the current authenticated Snyk user's ID. + * + * If no user is authenticated, this will return null. + */ + override fun userId(): String? { + return getSnykApiService().userId + } +} diff --git a/src/main/kotlin/io/snyk/plugin/extensions/SnykControllerManager.kt b/src/main/kotlin/io/snyk/plugin/extensions/SnykControllerManager.kt new file mode 100644 index 000000000..700157dcc --- /dev/null +++ b/src/main/kotlin/io/snyk/plugin/extensions/SnykControllerManager.kt @@ -0,0 +1,13 @@ +package io.snyk.plugin.extensions + +/** + * SnykControllerManager is the extension point interface + * which other plugins can implement in order to integrate with Snyk. + */ +interface SnykControllerManager { + + /** + * register is called by the Snyk IntelliJ plugin to pass the #SnykController to extension point implementers. + */ + fun register(controller: SnykController) +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a030417dd..0086cb328 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -100,4 +100,10 @@ description="Snyk: Show Low severity issues"/> + + + + diff --git a/src/test/kotlin/io/snyk/plugin/extensions/SnykControllerImplTest.kt b/src/test/kotlin/io/snyk/plugin/extensions/SnykControllerImplTest.kt new file mode 100644 index 000000000..c7373371e --- /dev/null +++ b/src/test/kotlin/io/snyk/plugin/extensions/SnykControllerImplTest.kt @@ -0,0 +1,99 @@ +package io.snyk.plugin.extensions + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.testFramework.LightPlatformTestCase +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.replaceService +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import io.snyk.plugin.extensions.SnykControllerImpl +import io.snyk.plugin.getCliFile +import io.snyk.plugin.getContainerService +import io.snyk.plugin.getIacService +import io.snyk.plugin.getOssService +import io.snyk.plugin.getSnykCachedResults +import io.snyk.plugin.getSnykCliDownloaderService +import io.snyk.plugin.isCliInstalled +import io.snyk.plugin.isContainerEnabled +import io.snyk.plugin.isIacEnabled +import io.snyk.plugin.net.CliConfigSettings +import io.snyk.plugin.net.LocalCodeEngine +import io.snyk.plugin.pluginSettings +import io.snyk.plugin.removeDummyCliFile +import io.snyk.plugin.resetSettings +import io.snyk.plugin.services.SnykApiService +import io.snyk.plugin.services.SnykTaskQueueService +import io.snyk.plugin.services.download.CliDownloader +import io.snyk.plugin.services.download.LatestReleaseInfo +import io.snyk.plugin.services.download.SnykCliDownloaderService +import io.snyk.plugin.setupDummyCliFile +import org.awaitility.Awaitility.await +import snyk.container.ContainerResult +import snyk.iac.IacResult +import snyk.oss.OssResult +import snyk.oss.OssService +import snyk.trust.confirmScanningAndSetWorkspaceTrustedStateIfNeeded +import java.util.concurrent.TimeUnit + +@Suppress("FunctionName") +class SnykControllerImplTest : LightPlatformTestCase() { + + private lateinit var ossServiceMock: OssService + private lateinit var downloaderServiceMock: SnykCliDownloaderService + + override fun setUp() { + super.setUp() + unmockkAll() + resetSettings(project) + ossServiceMock = mockk(relaxed = true) + project.replaceService(OssService::class.java, ossServiceMock, project) + mockkStatic("io.snyk.plugin.UtilsKt") + mockkStatic("snyk.trust.TrustedProjectsKt") + downloaderServiceMock = spyk(SnykCliDownloaderService()) + every { downloaderServiceMock.requestLatestReleasesInformation() } returns LatestReleaseInfo( + "http://testUrl", + "testReleaseInfo", + "testTag" + ) + every { getSnykCliDownloaderService() } returns downloaderServiceMock + every { downloaderServiceMock.isFourDaysPassedSinceLastCheck() } returns false + every { confirmScanningAndSetWorkspaceTrustedStateIfNeeded(any(), any()) } returns true + } + + override fun tearDown() { + unmockkAll() + resetSettings(project) + removeDummyCliFile() + super.tearDown() + } + + fun testControllerCanTriggerScan() { + mockkStatic("io.snyk.plugin.UtilsKt") + every { isCliInstalled() } returns true + val fakeResult = OssResult(emptyList()) + every { getOssService(project)?.scan() } returns fakeResult + + val settings = pluginSettings() + settings.ossScanEnable = true + settings.snykCodeSecurityIssuesScanEnable = false + settings.snykCodeQualityIssuesScanEnable = false + settings.iacScanEnabled = false + settings.containerScanEnabled = false + + getSnykCachedResults(project)?.currentContainerResult = null + + val controller = SnykControllerImpl(project) + controller.scan() + + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + + await().atMost(2, TimeUnit.SECONDS).until { + getSnykCachedResults(project)?.currentOssResults != null + } + } +}