diff --git a/docs/components/Snyk.mdx b/docs/components/Snyk.mdx
new file mode 100644
index 0000000000..0f76cda580
--- /dev/null
+++ b/docs/components/Snyk.mdx
@@ -0,0 +1,130 @@
+---
+title: "Snyk"
+---
+
+Security workflow integration with Snyk
+
+import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+## Triggers
+
+
+
+
+
+## Actions
+
+
+
+
+
+## Instructions
+
+To get a Snyk API token, go to your [Snyk Personal Access Tokens](https://app.snyk.io/account/personal-access-tokens) page.
+
+
+
+## On New Issue Detected
+
+The On New Issue Detected trigger starts a workflow execution when Snyk detects new security issues in a project.
+
+### Use Cases
+
+- **Security alerts**: Get notified immediately when new vulnerabilities are found
+- **Ticket creation**: Automatically create tickets for new security issues
+- **Compliance workflows**: Trigger compliance processes when issues are detected
+- **Team notifications**: Notify security teams of new findings
+
+### Configuration
+
+- **Project**: Optional project filter - select a project to only trigger on issues from that project
+- **Severity**: Optional severity filter - select one or more severities to trigger on (low, medium, high, critical)
+
+### Event Data
+
+Each issue detection event includes:
+- **issue**: Issue information including ID, title, severity, and description
+- **project**: Project information where the issue was found
+- **org**: Organization information
+
+### Webhook Setup
+
+This trigger automatically sets up a Snyk webhook when configured. The webhook is managed by SuperPlane and will be cleaned up when the trigger is removed.
+
+### Example Data
+
+```json
+{
+ "data": {
+ "issue": {
+ "id": "SNYK-JS-12345",
+ "title": "Remote Code Execution",
+ "severity": "high",
+ "description": "A vulnerability in the package allows remote code execution",
+ "packageName": "lodash",
+ "packageVersion": "4.17.20"
+ },
+ "project": {
+ "id": "project-123",
+ "name": "my-web-app"
+ },
+ "org": {
+ "id": "org-123",
+ "name": "my-org"
+ }
+ },
+ "timestamp": "2026-01-15T10:30:00Z",
+ "type": "snyk.issue.detected"
+}
+```
+
+
+
+## Ignore Issue
+
+The Ignore Issue component ignores a specific Snyk security issue via the Snyk API.
+
+### Use Cases
+
+- **Risk acceptance**: Temporarily accept risks while a fix is being developed
+- **False positive handling**: Suppress issues that are determined to be false positives
+- **Automated suppression**: Automatically ignore issues based on predefined criteria
+- **Workflow integration**: Integrate issue ignoring into broader security workflows
+
+### Configuration
+
+- **Project ID**: The project ID where the issue exists
+- **Issue ID**: The specific issue ID to ignore
+- **Reason**: The reason for ignoring the issue
+- **Expires At**: Optional expiration date for the ignore rule (ISO 8601 format)
+
+### Output
+
+Returns information about the ignored issue including:
+- **success**: Whether the operation succeeded
+- **message**: Status message from the API
+- **projectId**: The project ID
+- **issueId**: The issue that was ignored
+- **reason**: The reason provided
+
+### Notes
+
+- The issue will no longer appear in Snyk reports after being ignored
+- Ignored issues can be unignored later through the Snyk UI or API
+- Requires a Snyk plan with API access
+
+### Example Output
+
+```json
+{
+ "data": {
+ "success": true,
+ "message": "Issue SNYK-JS-12345 ignored successfully",
+ "projectId": "project-123",
+ "issueId": "SNYK-JS-12345",
+ "reason": "Acceptable risk for this dependency"
+ },
+ "timestamp": "2026-01-15T10:30:00Z",
+ "type": "snyk.issue.ignored"
+}
+```
diff --git a/pkg/integrations/snyk/client.go b/pkg/integrations/snyk/client.go
new file mode 100644
index 0000000000..1b3d643598
--- /dev/null
+++ b/pkg/integrations/snyk/client.go
@@ -0,0 +1,299 @@
+package snyk
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const (
+ BaseURL = "https://api.snyk.io"
+ Version = "2024-06-10"
+)
+
+type Client struct {
+ httpClient core.HTTPContext
+ integration core.IntegrationContext
+ baseURL string
+ version string
+}
+
+func NewClient(httpCtx core.HTTPContext, integration core.IntegrationContext) (*Client, error) {
+ apiToken, err := integration.GetConfig("apiToken")
+ if err != nil {
+ return nil, fmt.Errorf("error getting API token: %v", err)
+ }
+
+ if len(apiToken) == 0 || string(apiToken) == "" {
+ return nil, fmt.Errorf("apiToken is required")
+ }
+
+ return &Client{
+ httpClient: httpCtx,
+ integration: integration,
+ baseURL: BaseURL,
+ version: Version,
+ }, nil
+}
+
+type UserResponse struct {
+ Data struct {
+ ID string `json:"id"`
+ Attributes struct {
+ Name string `json:"name"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ DefaultOrgContext string `json:"default_org_context"`
+ } `json:"attributes"`
+ } `json:"data"`
+}
+
+func (c *Client) GetUser() (*UserResponse, error) {
+ url := fmt.Sprintf("%s/rest/self?version=%s", c.baseURL, c.version)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ apiToken, err := c.integration.GetConfig("apiToken")
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("token %s", string(apiToken)))
+ req.Header.Set("Content-Type", "application/vnd.api+json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var userResp UserResponse
+ err = json.Unmarshal(body, &userResp)
+ if err != nil {
+ return nil, err
+ }
+
+ return &userResp, nil
+}
+
+type SnykProject struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type ListProjectsResponse struct {
+ Data []struct {
+ ID string `json:"id"`
+ Attributes struct {
+ Name string `json:"name"`
+ } `json:"attributes"`
+ } `json:"data"`
+}
+
+func (c *Client) ListProjects(orgID string) ([]SnykProject, error) {
+ url := fmt.Sprintf("%s/rest/orgs/%s/projects?version=%s&limit=100", c.baseURL, orgID, c.version)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ apiToken, err := c.integration.GetConfig("apiToken")
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("token %s", string(apiToken)))
+ req.Header.Set("Content-Type", "application/vnd.api+json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var projectsResp ListProjectsResponse
+ if err := json.Unmarshal(body, &projectsResp); err != nil {
+ return nil, err
+ }
+
+ projects := make([]SnykProject, 0, len(projectsResp.Data))
+ for _, p := range projectsResp.Data {
+ projects = append(projects, SnykProject{
+ ID: p.ID,
+ Name: p.Attributes.Name,
+ })
+ }
+
+ return projects, nil
+}
+
+type IgnoreIssueRequest struct {
+ Reason string `json:"reason"`
+ ReasonType string `json:"reasonType,omitempty"`
+ DisregardIfFixable bool `json:"disregardIfFixable"`
+ Expires string `json:"expires,omitempty"`
+}
+
+type IgnoreIssueResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message,omitempty"`
+}
+
+func (c *Client) IgnoreIssue(orgID, projectID, issueID string, req IgnoreIssueRequest) (*IgnoreIssueResponse, error) {
+ url := fmt.Sprintf("%s/v1/org/%s/project/%s/ignore/%s", c.baseURL, orgID, projectID, issueID)
+
+ jsonData, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+
+ httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return nil, err
+ }
+
+ apiToken, err := c.integration.GetConfig("apiToken")
+ if err != nil {
+ return nil, err
+ }
+
+ httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", string(apiToken)))
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ // Snyk's ignore API might not return JSON, so we handle both cases
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return &IgnoreIssueResponse{
+ Success: true,
+ Message: fmt.Sprintf("Issue %s ignored successfully", issueID),
+ }, nil
+ }
+
+ return &IgnoreIssueResponse{
+ Success: false,
+ Message: fmt.Sprintf("Failed to ignore issue %s: %s", issueID, string(body)),
+ }, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+}
+
+func (c *Client) RegisterWebhook(orgID, url, secret string) (string, error) {
+ registerURL := fmt.Sprintf("%s/v1/org/%s/webhooks", c.baseURL, orgID)
+
+ requestBody := map[string]any{
+ "url": url,
+ "secret": secret,
+ }
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return "", err
+ }
+
+ httpReq, err := http.NewRequest("POST", registerURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return "", err
+ }
+
+ apiToken, err := c.integration.GetConfig("apiToken")
+ if err != nil {
+ return "", err
+ }
+
+ httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", string(apiToken)))
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("webhook registration failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ var response map[string]any
+ if err := json.Unmarshal(body, &response); err != nil {
+ return "", fmt.Errorf("failed to parse webhook registration response: %w", err)
+ }
+
+ if id, ok := response["id"]; ok {
+ if idStr, isString := id.(string); isString {
+ return idStr, nil
+ }
+ return "", fmt.Errorf("webhook ID in response is not a string")
+ }
+
+ return "", fmt.Errorf("webhook ID not found in response")
+}
+
+func (c *Client) DeleteWebhook(orgID, webhookID string) error {
+ deleteURL := fmt.Sprintf("%s/v1/org/%s/webhooks/%s", c.baseURL, orgID, webhookID)
+
+ httpReq, err := http.NewRequest("DELETE", deleteURL, nil)
+ if err != nil {
+ return err
+ }
+
+ apiToken, err := c.integration.GetConfig("apiToken")
+ if err != nil {
+ return err
+ }
+
+ httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", string(apiToken)))
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("webhook deletion failed with status %d: %s", resp.StatusCode, string(body))
+}
diff --git a/pkg/integrations/snyk/example.go b/pkg/integrations/snyk/example.go
new file mode 100644
index 0000000000..8d6244ea86
--- /dev/null
+++ b/pkg/integrations/snyk/example.go
@@ -0,0 +1,28 @@
+package snyk
+
+import (
+ _ "embed"
+ "sync"
+
+ "github.com/superplanehq/superplane/pkg/utils"
+)
+
+//go:embed example_output_ignore_issue.json
+var exampleOutputIgnoreIssueBytes []byte
+
+//go:embed example_data_on_new_issue_detected.json
+var exampleDataOnNewIssueDetectedBytes []byte
+
+var exampleOutputIgnoreIssueOnce sync.Once
+var exampleOutputIgnoreIssue map[string]any
+
+var exampleDataOnNewIssueDetectedOnce sync.Once
+var exampleDataOnNewIssueDetected map[string]any
+
+func (c *IgnoreIssue) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputIgnoreIssueOnce, exampleOutputIgnoreIssueBytes, &exampleOutputIgnoreIssue)
+}
+
+func (t *OnNewIssueDetected) ExampleData() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleDataOnNewIssueDetectedOnce, exampleDataOnNewIssueDetectedBytes, &exampleDataOnNewIssueDetected)
+}
diff --git a/pkg/integrations/snyk/example_data_on_new_issue_detected.json b/pkg/integrations/snyk/example_data_on_new_issue_detected.json
new file mode 100644
index 0000000000..b4c9e93805
--- /dev/null
+++ b/pkg/integrations/snyk/example_data_on_new_issue_detected.json
@@ -0,0 +1,22 @@
+{
+ "data": {
+ "issue": {
+ "id": "SNYK-JS-12345",
+ "title": "Remote Code Execution",
+ "severity": "high",
+ "description": "A vulnerability in the package allows remote code execution",
+ "packageName": "lodash",
+ "packageVersion": "4.17.20"
+ },
+ "project": {
+ "id": "project-123",
+ "name": "my-web-app"
+ },
+ "org": {
+ "id": "org-123",
+ "name": "my-org"
+ }
+ },
+ "timestamp": "2026-01-15T10:30:00Z",
+ "type": "snyk.issue.detected"
+}
diff --git a/pkg/integrations/snyk/example_output_ignore_issue.json b/pkg/integrations/snyk/example_output_ignore_issue.json
new file mode 100644
index 0000000000..12413317fb
--- /dev/null
+++ b/pkg/integrations/snyk/example_output_ignore_issue.json
@@ -0,0 +1,11 @@
+{
+ "data": {
+ "success": true,
+ "message": "Issue SNYK-JS-12345 ignored successfully",
+ "projectId": "project-123",
+ "issueId": "SNYK-JS-12345",
+ "reason": "Acceptable risk for this dependency"
+ },
+ "timestamp": "2026-01-15T10:30:00Z",
+ "type": "snyk.issue.ignored"
+}
diff --git a/pkg/integrations/snyk/ignore_issue.go b/pkg/integrations/snyk/ignore_issue.go
new file mode 100644
index 0000000000..c9b9f00f20
--- /dev/null
+++ b/pkg/integrations/snyk/ignore_issue.go
@@ -0,0 +1,201 @@
+package snyk
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type IgnoreIssue struct{}
+
+type IgnoreIssueConfiguration struct {
+ ProjectID string `json:"projectId" mapstructure:"projectId"`
+ IssueID string `json:"issueId" mapstructure:"issueId"`
+ Reason string `json:"reason" mapstructure:"reason"`
+ ExpiresAt string `json:"expiresAt" mapstructure:"expiresAt"` // Optional
+}
+
+type IgnoreIssueMetadata struct {
+ ProjectID string `json:"projectId" mapstructure:"projectId"`
+ IssueID string `json:"issueId" mapstructure:"issueId"`
+}
+
+func (c *IgnoreIssue) Name() string {
+ return "snyk.ignoreIssue"
+}
+
+func (c *IgnoreIssue) Label() string {
+ return "Ignore Issue"
+}
+
+func (c *IgnoreIssue) Description() string {
+ return "Ignore a specific Snyk security issue"
+}
+
+func (c *IgnoreIssue) Documentation() string {
+ return `The Ignore Issue component allows you to programmatically ignore a specific Snyk security issue.
+
+## Use Cases
+
+- **Risk acceptance**: Temporarily accept risks while a fix is being developed
+- **False positive handling**: Suppress issues that are determined to be false positives
+- **Automated suppression**: Automatically ignore issues based on predefined criteria
+- **Workflow integration**: Integrate issue ignoring into broader security workflows
+
+## Configuration
+
+- **Project ID**: The project ID where the issue exists
+- **Issue ID**: The specific issue ID to ignore
+- **Reason**: The reason for ignoring the issue
+- **Expires At**: Optional expiration date for the ignore rule (ISO 8601 format)
+
+## Output
+
+Returns information about the ignored issue including success status and any messages from the API.
+
+## Notes
+
+- The issue will no longer appear in Snyk reports after being ignored
+- Ignored issues can be unignored later through the Snyk UI or API`
+}
+
+func (c *IgnoreIssue) Icon() string {
+ return "shield"
+}
+
+func (c *IgnoreIssue) Color() string {
+ return "gray"
+}
+
+func (c *IgnoreIssue) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *IgnoreIssue) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "projectId",
+ Label: "Project ID",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "Snyk project ID containing the issue",
+ },
+ {
+ Name: "issueId",
+ Label: "Issue ID",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "The specific issue ID to ignore",
+ },
+ {
+ Name: "reason",
+ Label: "Reason",
+ Type: configuration.FieldTypeText,
+ Required: true,
+ Description: "Reason for ignoring the issue",
+ },
+ {
+ Name: "expiresAt",
+ Label: "Expires At",
+ Type: configuration.FieldTypeString,
+ Required: false,
+ Description: "Optional expiration date for the ignore rule (ISO 8601 format)",
+ },
+ }
+}
+
+func (c *IgnoreIssue) Setup(ctx core.SetupContext) error {
+ var config IgnoreIssueConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ if config.ProjectID == "" {
+ return errors.New("projectId is required")
+ }
+
+ if config.IssueID == "" {
+ return errors.New("issueId is required")
+ }
+
+ if config.Reason == "" {
+ return errors.New("reason is required")
+ }
+
+ metadata := IgnoreIssueMetadata{
+ ProjectID: config.ProjectID,
+ IssueID: config.IssueID,
+ }
+
+ return ctx.Metadata.Set(metadata)
+}
+
+func (c *IgnoreIssue) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *IgnoreIssue) Execute(ctx core.ExecutionContext) error {
+ var config IgnoreIssueConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ orgID, err := ctx.Integration.GetConfig("organizationId")
+ if err != nil {
+ return fmt.Errorf("error getting organizationId: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create Snyk client: %w", err)
+ }
+
+ ignoreReq := IgnoreIssueRequest{
+ Reason: config.Reason,
+ ReasonType: "temporary-ignore",
+ Expires: config.ExpiresAt,
+ }
+
+ response, err := client.IgnoreIssue(string(orgID), config.ProjectID, config.IssueID, ignoreReq)
+ if err != nil {
+ return fmt.Errorf("failed to ignore issue: %w", err)
+ }
+
+ result := map[string]any{
+ "success": response.Success,
+ "message": response.Message,
+ "projectId": config.ProjectID,
+ "issueId": config.IssueID,
+ "reason": config.Reason,
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "snyk.issue.ignored",
+ []any{result},
+ )
+}
+
+func (c *IgnoreIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return 200, nil
+}
+
+func (c *IgnoreIssue) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (c *IgnoreIssue) HandleAction(ctx core.ActionContext) error {
+ return nil
+}
+
+func (c *IgnoreIssue) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *IgnoreIssue) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
diff --git a/pkg/integrations/snyk/ignore_issue_test.go b/pkg/integrations/snyk/ignore_issue_test.go
new file mode 100644
index 0000000000..e4b65a4291
--- /dev/null
+++ b/pkg/integrations/snyk/ignore_issue_test.go
@@ -0,0 +1,225 @@
+package snyk
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func TestIgnoreIssueComponent(t *testing.T) {
+ component := &IgnoreIssue{}
+
+ assert.Equal(t, "snyk.ignoreIssue", component.Name())
+ assert.Equal(t, "Ignore Issue", component.Label())
+ assert.Equal(t, "Ignore a specific Snyk security issue", component.Description())
+ assert.Equal(t, "shield", component.Icon())
+
+ configFields := component.Configuration()
+ assert.Len(t, configFields, 4)
+
+ fieldNames := make(map[string]bool)
+ for _, field := range configFields {
+ fieldNames[field.Name] = true
+ }
+
+ expectedFields := []string{"projectId", "issueId", "reason", "expiresAt"}
+ for _, fieldName := range expectedFields {
+ assert.True(t, fieldNames[fieldName], "Missing field: %s", fieldName)
+ }
+}
+
+func TestIgnoreIssueExampleOutput(t *testing.T) {
+ component := &IgnoreIssue{}
+ example := component.ExampleOutput()
+
+ assert.NotNil(t, example)
+}
+
+func Test__IgnoreIssue__Setup(t *testing.T) {
+ component := &IgnoreIssue{}
+
+ tests := []struct {
+ name string
+ configuration map[string]any
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "missing projectId",
+ configuration: map[string]any{"issueId": "i1", "reason": "test"},
+ expectError: true,
+ errorContains: "projectId is required",
+ },
+ {
+ name: "missing issueId",
+ configuration: map[string]any{"projectId": "p1", "reason": "test"},
+ expectError: true,
+ errorContains: "issueId is required",
+ },
+ {
+ name: "missing reason",
+ configuration: map[string]any{"projectId": "p1", "issueId": "i1"},
+ expectError: true,
+ errorContains: "reason is required",
+ },
+ {
+ name: "valid configuration",
+ configuration: map[string]any{
+ "projectId": "p1",
+ "issueId": "SNYK-JS-123",
+ "reason": "false positive",
+ },
+ expectError: false,
+ },
+ {
+ name: "valid configuration with expiresAt",
+ configuration: map[string]any{
+ "projectId": "p1",
+ "issueId": "SNYK-JS-123",
+ "reason": "temporary acceptance",
+ "expiresAt": "2026-06-01T00:00:00Z",
+ },
+ expectError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ metadata := &contexts.MetadataContext{}
+ ctx := core.SetupContext{
+ Configuration: tt.configuration,
+ Metadata: metadata,
+ }
+
+ err := component.Setup(ctx)
+ if tt.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errorContains)
+ } else {
+ require.NoError(t, err)
+ assert.NotNil(t, metadata.Metadata)
+ }
+ })
+ }
+}
+
+func Test__IgnoreIssue__Execute(t *testing.T) {
+ component := &IgnoreIssue{}
+
+ t.Run("successful ignore", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"ok": true}`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ "organizationId": "org-123",
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "projectId": "proj-456",
+ "issueId": "SNYK-JS-789",
+ "reason": "false positive",
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ ExecutionState: execState,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, execState.Finished)
+ assert.True(t, execState.Passed)
+ assert.Equal(t, "snyk.issue.ignored", execState.Type)
+ assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel)
+ require.Len(t, execState.Payloads, 1)
+
+ // Verify the HTTP request was made correctly
+ require.Len(t, httpCtx.Requests, 1)
+ req := httpCtx.Requests[0]
+ assert.Equal(t, "POST", req.Method)
+ assert.Contains(t, req.URL.String(), "/v1/org/org-123/project/proj-456/ignore/SNYK-JS-789")
+ })
+
+ t.Run("API error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusForbidden,
+ Body: io.NopCloser(strings.NewReader(`{"error": "forbidden"}`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ "organizationId": "org-123",
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "projectId": "proj-456",
+ "issueId": "SNYK-JS-789",
+ "reason": "false positive",
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ ExecutionState: execState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to ignore issue")
+ assert.False(t, execState.Finished)
+ })
+
+ t.Run("missing API token", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{}
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "organizationId": "org-123",
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "projectId": "proj-456",
+ "issueId": "SNYK-JS-789",
+ "reason": "false positive",
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ ExecutionState: execState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to create Snyk client")
+ })
+}
diff --git a/pkg/integrations/snyk/on_new_issue_detected.go b/pkg/integrations/snyk/on_new_issue_detected.go
new file mode 100644
index 0000000000..c3f2284ce3
--- /dev/null
+++ b/pkg/integrations/snyk/on_new_issue_detected.go
@@ -0,0 +1,261 @@
+package snyk
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/crypto"
+)
+
+type OnNewIssueDetected struct{}
+
+type OnNewIssueDetectedConfiguration struct {
+ ProjectID string `json:"projectId" mapstructure:"projectId"` // Optional - if not specified, applies to all projects
+ Severity []string `json:"severity" mapstructure:"severity"` // Optional - filter by severity (low, medium, high, critical)
+}
+
+func (t *OnNewIssueDetected) Name() string {
+ return "snyk.onNewIssueDetected"
+}
+
+func (t *OnNewIssueDetected) Label() string {
+ return "On New Issue Detected"
+}
+
+func (t *OnNewIssueDetected) Description() string {
+ return "Listen to Snyk for new security issues"
+}
+
+func (t *OnNewIssueDetected) Documentation() string {
+ return `The On New Issue Detected trigger starts a workflow execution when Snyk detects new security issues.
+
+## Use Cases
+
+- **Security alerts**: Get notified immediately when new vulnerabilities are found
+- **Ticket creation**: Automatically create tickets for new security issues
+- **Compliance workflows**: Trigger compliance processes when issues are detected
+- **Team notifications**: Notify security teams of new findings
+
+## Configuration
+
+- **Project**: Optional project filter - select a project to only trigger on issues from that project
+- **Severity**: Optional severity filter - select one or more severities to trigger on (low, medium, high, critical)
+
+## Event Data
+
+Each issue detection event includes:
+- **issue**: Issue information including ID, title, severity, and description
+- **project**: Project information where the issue was found
+- **package**: Package information related to the issue
+- **timestamp**: When the issue was detected
+
+## Webhook Setup
+
+This trigger automatically sets up a Snyk webhook when configured. The webhook is managed by SuperPlane and will be cleaned up when the trigger is removed.`
+}
+
+func (t *OnNewIssueDetected) Icon() string {
+ return "shield"
+}
+
+func (t *OnNewIssueDetected) Color() string {
+ return "gray"
+}
+
+func (t *OnNewIssueDetected) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "projectId",
+ Label: "Project",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: false,
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "project",
+ },
+ },
+ Description: "Optional project filter - if specified, only issues from this project will trigger",
+ },
+ {
+ Name: "severity",
+ Label: "Severity",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: false,
+ Description: "Optional severity filter - only issues with the selected severities will trigger",
+ TypeOptions: &configuration.TypeOptions{
+ MultiSelect: &configuration.MultiSelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Low", Value: "low"},
+ {Label: "Medium", Value: "medium"},
+ {Label: "High", Value: "high"},
+ {Label: "Critical", Value: "critical"},
+ },
+ },
+ },
+ },
+ }
+}
+
+func (t *OnNewIssueDetected) Setup(ctx core.TriggerContext) error {
+ var config OnNewIssueDetectedConfiguration
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ orgID, err := ctx.Integration.GetConfig("organizationId")
+ if err != nil {
+ return fmt.Errorf("error getting organizationId: %v", err)
+ }
+
+ if string(orgID) == "" {
+ return fmt.Errorf("organizationId is required in integration configuration")
+ }
+
+ webhookConfig := WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: string(orgID),
+ ProjectID: config.ProjectID,
+ }
+
+ return ctx.Integration.RequestWebhook(webhookConfig)
+}
+
+func (t *OnNewIssueDetected) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (t *OnNewIssueDetected) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) {
+ return nil, nil
+}
+
+func (t *OnNewIssueDetected) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ config := OnNewIssueDetectedConfiguration{}
+ err := mapstructure.Decode(ctx.Configuration, &config)
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ signature := ctx.Headers.Get("X-Hub-Signature")
+ if signature == "" {
+ return http.StatusForbidden, fmt.Errorf("missing signature")
+ }
+
+ signature = strings.TrimPrefix(signature, "sha256=")
+ if signature == "" {
+ return http.StatusForbidden, fmt.Errorf("invalid signature format")
+ }
+
+ secret, err := ctx.Webhook.GetSecret()
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error getting secret: %v", err)
+ }
+
+ if err := crypto.VerifySignature(secret, ctx.Body, signature); err != nil {
+ return http.StatusForbidden, fmt.Errorf("invalid signature: %v", err)
+ }
+
+ eventType := ctx.Headers.Get("X-Snyk-Event")
+ if eventType == "" {
+ // Alternative header that Snyk might use
+ eventType = ctx.Headers.Get("X-Snyk-Event-Type")
+ if eventType == "" {
+ return http.StatusBadRequest, fmt.Errorf("missing Snyk event header")
+ }
+ }
+
+ // Snyk sends project_snapshot/v0 events that contain new issues
+ if eventType != "project_snapshot/v0" {
+ return http.StatusOK, nil
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(ctx.Body, &payload); err != nil {
+ return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err)
+ }
+
+ newIssues, ok := payload["newIssues"].([]any)
+ if !ok || len(newIssues) == 0 {
+ return http.StatusOK, nil
+ }
+
+ for _, issue := range newIssues {
+ issueMap, ok := issue.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ issuePayload := map[string]any{
+ "issue": issueMap,
+ "project": payload["project"],
+ "org": payload["org"],
+ }
+
+ if t.matchesFilters(issuePayload, config) {
+ if err := ctx.Events.Emit("snyk.issue.detected", issuePayload); err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err)
+ }
+ }
+ }
+
+ return http.StatusOK, nil
+}
+
+func (t *OnNewIssueDetected) matchesFilters(payload map[string]any, config OnNewIssueDetectedConfiguration) bool {
+ if config.ProjectID != "" {
+ projectData, ok := payload["project"]
+ if !ok {
+ return false
+ }
+
+ projectMap, isMap := projectData.(map[string]any)
+ if !isMap {
+ return false
+ }
+
+ idStr, isString := projectMap["id"].(string)
+ if !isString || idStr != config.ProjectID {
+ return false
+ }
+ }
+
+ if len(config.Severity) > 0 {
+ issueRaw, ok := payload["issue"].(map[string]any)
+ if !ok {
+ return false
+ }
+
+ // Support both flat (severity at top level) and nested (issueData.severity) formats.
+ severityStr, isString := issueRaw["severity"].(string)
+ if !isString {
+ if issueData, ok := issueRaw["issueData"].(map[string]any); ok {
+ severityStr, isString = issueData["severity"].(string)
+ }
+ if !isString {
+ return false
+ }
+ }
+
+ matched := false
+ for _, s := range config.Severity {
+ if severityStr == s {
+ matched = true
+ break
+ }
+ }
+
+ if !matched {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (t *OnNewIssueDetected) Cleanup(ctx core.TriggerContext) error {
+ return nil
+}
diff --git a/pkg/integrations/snyk/on_new_issue_detected_test.go b/pkg/integrations/snyk/on_new_issue_detected_test.go
new file mode 100644
index 0000000000..906e77331d
--- /dev/null
+++ b/pkg/integrations/snyk/on_new_issue_detected_test.go
@@ -0,0 +1,420 @@
+package snyk
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ contexts "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func TestOnNewIssueDetectedTrigger(t *testing.T) {
+ trigger := &OnNewIssueDetected{}
+
+ assert.Equal(t, "snyk.onNewIssueDetected", trigger.Name())
+ assert.Equal(t, "On New Issue Detected", trigger.Label())
+ assert.Equal(t, "Listen to Snyk for new security issues", trigger.Description())
+ assert.Equal(t, "shield", trigger.Icon())
+
+ configFields := trigger.Configuration()
+ assert.Len(t, configFields, 2)
+
+ fieldNames := make(map[string]bool)
+ for _, field := range configFields {
+ fieldNames[field.Name] = true
+ }
+
+ expectedFields := []string{"projectId", "severity"}
+ for _, fieldName := range expectedFields {
+ assert.True(t, fieldNames[fieldName], "Missing field: %s", fieldName)
+ }
+}
+
+func Test__OnNewIssueDetected__HandleWebhook(t *testing.T) {
+ trigger := &OnNewIssueDetected{}
+ secret := "test-webhook-secret"
+
+ signatureFor := func(secret string, body []byte) string {
+ h := hmac.New(sha256.New, []byte(secret))
+ h.Write(body)
+ return fmt.Sprintf("sha256=%x", h.Sum(nil))
+ }
+
+ t.Run("missing signature -> 403", func(t *testing.T) {
+ headers := http.Header{}
+
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Headers: headers,
+ Configuration: map[string]any{},
+ Webhook: &contexts.WebhookContext{},
+ })
+
+ assert.Equal(t, http.StatusForbidden, code)
+ assert.ErrorContains(t, err, "missing signature")
+ })
+
+ t.Run("invalid signature -> 403", func(t *testing.T) {
+ body := []byte(`{}`)
+ headers := http.Header{}
+ headers.Set("X-Hub-Signature", "sha256=invalidsignature")
+
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusForbidden, code)
+ assert.ErrorContains(t, err, "invalid signature")
+ })
+
+ t.Run("missing event header -> 400", func(t *testing.T) {
+ body := []byte(`{}`)
+ headers := http.Header{}
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusBadRequest, code)
+ assert.ErrorContains(t, err, "missing Snyk event header")
+ })
+
+ t.Run("non-matching event type -> 200, no events emitted", func(t *testing.T) {
+ body := []byte(`{}`)
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "ping")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Zero(t, eventContext.Count())
+ })
+
+ t.Run("invalid JSON body -> 400", func(t *testing.T) {
+ body := []byte(`not json`)
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Events: &contexts.EventContext{},
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusBadRequest, code)
+ assert.ErrorContains(t, err, "error parsing request body")
+ })
+
+ t.Run("no new issues -> 200, no events emitted", func(t *testing.T) {
+ body := []byte(`{"newIssues": [], "project": {"id": "p1"}}`)
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Zero(t, eventContext.Count())
+ })
+
+ t.Run("new issue detected -> event emitted", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {
+ "id": "SNYK-JS-12345",
+ "title": "Remote Code Execution",
+ "severity": "high",
+ "packageName": "lodash",
+ "packageVersion": "4.17.20"
+ }
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, eventContext.Count())
+ assert.Equal(t, "snyk.issue.detected", eventContext.Payloads[0].Type)
+ })
+
+ t.Run("multiple new issues -> multiple events emitted", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001", "severity": "high"},
+ {"id": "SNYK-JS-002", "severity": "critical"}
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, eventContext.Count())
+ })
+
+ t.Run("severity filter -> only exact matching issues emitted", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001", "severity": "low"},
+ {"id": "SNYK-JS-002", "severity": "high"},
+ {"id": "SNYK-JS-003", "severity": "critical"}
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{
+ "severity": []string{"high", "critical"},
+ },
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, eventContext.Count())
+ })
+
+ t.Run("severity filter -> missing severity in issue rejects", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001"}
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{
+ "severity": []string{"high"},
+ },
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Zero(t, eventContext.Count())
+ })
+
+ t.Run("project filter -> missing project data rejects", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001", "severity": "high"}
+ ],
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{
+ "projectId": "project-123",
+ },
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Zero(t, eventContext.Count())
+ })
+
+ t.Run("project filter -> only matching project emitted", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001", "severity": "high"}
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{
+ "projectId": "project-999",
+ },
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Zero(t, eventContext.Count())
+ })
+
+ t.Run("project filter matches -> event emitted", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001", "severity": "high"}
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{
+ "projectId": "project-123",
+ },
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, eventContext.Count())
+ })
+
+ t.Run("X-Snyk-Event-Type header also works", func(t *testing.T) {
+ body := []byte(`{
+ "newIssues": [
+ {"id": "SNYK-JS-001", "severity": "high"}
+ ],
+ "project": {"id": "project-123", "name": "my-web-app"},
+ "org": {"id": "org-123", "name": "my-org"}
+ }`)
+
+ headers := http.Header{}
+ headers.Set("X-Snyk-Event-Type", "project_snapshot/v0")
+ headers.Set("X-Hub-Signature", signatureFor(secret, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{},
+ Events: eventContext,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, eventContext.Count())
+ })
+}
+
+func Test__OnNewIssueDetected__Setup(t *testing.T) {
+ trigger := &OnNewIssueDetected{}
+
+ t.Run("organizationId is required in integration config", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{"organizationId": ""},
+ }
+ err := trigger.Setup(core.TriggerContext{
+ Integration: integrationCtx,
+ Configuration: map[string]any{},
+ })
+
+ require.ErrorContains(t, err, "organizationId is required")
+ })
+
+ t.Run("webhook is requested with correct config", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{"organizationId": "org-123"},
+ }
+ require.NoError(t, trigger.Setup(core.TriggerContext{
+ Integration: integrationCtx,
+ Configuration: map[string]any{
+ "projectId": "project-123",
+ },
+ }))
+
+ require.Len(t, integrationCtx.WebhookRequests, 1)
+
+ webhookRequest := integrationCtx.WebhookRequests[0].(WebhookConfiguration)
+ assert.Equal(t, "issue.detected", webhookRequest.EventType)
+ assert.Equal(t, "org-123", webhookRequest.OrgID)
+ assert.Equal(t, "project-123", webhookRequest.ProjectID)
+ })
+}
diff --git a/pkg/integrations/snyk/snyk.go b/pkg/integrations/snyk/snyk.go
new file mode 100644
index 0000000000..297f6618a3
--- /dev/null
+++ b/pkg/integrations/snyk/snyk.go
@@ -0,0 +1,274 @@
+package snyk
+
+import (
+ "fmt"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/registry"
+)
+
+func init() {
+ registry.RegisterIntegrationWithWebhookHandler("snyk", &Snyk{}, &SnykWebhookHandler{})
+}
+
+type Snyk struct{}
+
+type Configuration struct {
+ APIToken string `json:"apiToken"`
+ OrganizationID string `json:"organizationId"`
+}
+
+type Metadata struct {
+ Organizations []Organization `json:"organizations"`
+ User User `json:"user"`
+}
+
+type Organization struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type User struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+}
+
+func (s *Snyk) Name() string {
+ return "snyk"
+}
+
+func (s *Snyk) Label() string {
+ return "Snyk"
+}
+
+func (s *Snyk) Icon() string {
+ return "shield"
+}
+
+func (s *Snyk) Description() string {
+ return "Security workflow integration with Snyk"
+}
+
+func (s *Snyk) Instructions() string {
+ return `To set up the Snyk integration:
+
+1. Go to your **Snyk Personal Access Tokens** page (https://app.snyk.io/account/personal-access-tokens)
+2. Click **Generate Token** and give it a descriptive name
+3. Copy the token and paste it in the **API Token** field below
+4. Enter your **Organization ID** — you can find it in Snyk under Organization Settings > General`
+}
+
+func (s *Snyk) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "apiToken",
+ Label: "API Token",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Sensitive: true,
+ Description: "Snyk API token for authentication",
+ },
+ {
+ Name: "organizationId",
+ Label: "Organization ID",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "Snyk organization ID",
+ },
+ }
+}
+
+func (s *Snyk) Components() []core.Component {
+ return []core.Component{
+ &IgnoreIssue{},
+ }
+}
+
+func (s *Snyk) Triggers() []core.Trigger {
+ return []core.Trigger{
+ &OnNewIssueDetected{},
+ }
+}
+
+func (s *Snyk) Cleanup(ctx core.IntegrationCleanupContext) error {
+ return nil
+}
+
+func (s *Snyk) Sync(ctx core.SyncContext) error {
+ config := Configuration{}
+ err := mapstructure.Decode(ctx.Configuration, &config)
+ if err != nil {
+ return fmt.Errorf("failed to decode config: %v", err)
+ }
+
+ if config.APIToken == "" {
+ return fmt.Errorf("apiToken is required")
+ }
+
+ if config.OrganizationID == "" {
+ return fmt.Errorf("organizationId is required")
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ user, err := client.GetUser()
+ if err != nil {
+ return fmt.Errorf("error verifying credentials: %v", err)
+ }
+
+ ctx.Integration.SetMetadata(Metadata{
+ User: User{
+ ID: user.Data.ID,
+ Name: user.Data.Attributes.Name,
+ Email: user.Data.Attributes.Email,
+ Username: user.Data.Attributes.Username,
+ },
+ })
+ ctx.Integration.Ready()
+ return nil
+}
+
+func (s *Snyk) HandleRequest(ctx core.HTTPRequestContext) {
+ // The trigger-specific HandleWebhook method handles webhook events
+ // This integration-level handler is not needed
+ ctx.Response.WriteHeader(200)
+}
+
+type SnykWebhook struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+}
+
+type WebhookConfiguration struct {
+ EventType string `json:"eventType"`
+ OrgID string `json:"orgId"`
+ ProjectID string `json:"projectId,omitempty"`
+}
+
+type SnykWebhookHandler struct{}
+
+func (h *SnykWebhookHandler) CompareConfig(a, b any) (bool, error) {
+ configA := WebhookConfiguration{}
+ configB := WebhookConfiguration{}
+
+ err := mapstructure.Decode(a, &configA)
+ if err != nil {
+ return false, fmt.Errorf("failed to decode config A: %w", err)
+ }
+
+ err = mapstructure.Decode(b, &configB)
+ if err != nil {
+ return false, fmt.Errorf("failed to decode config B: %w", err)
+ }
+
+ return configA.EventType == configB.EventType &&
+ configA.OrgID == configB.OrgID &&
+ configA.ProjectID == configB.ProjectID, nil
+}
+
+func (h *SnykWebhookHandler) Merge(current, requested any) (any, bool, error) {
+ return current, false, nil
+}
+
+func (h *SnykWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) {
+ config := WebhookConfiguration{}
+ err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode webhook configuration: %w", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create Snyk client: %w", err)
+ }
+
+ webhookURL := ctx.Webhook.GetURL()
+
+ secret, err := ctx.Webhook.GetSecret()
+ if err != nil {
+ return nil, fmt.Errorf("error getting webhook secret: %w", err)
+ }
+
+ webhookID, err := client.RegisterWebhook(config.OrgID, webhookURL, string(secret))
+ if err != nil {
+ return nil, fmt.Errorf("error registering Snyk webhook: %w", err)
+ }
+
+ return &SnykWebhook{
+ ID: webhookID,
+ URL: webhookURL,
+ }, nil
+}
+
+func (h *SnykWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error {
+ webhook := SnykWebhook{}
+ err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &webhook)
+ if err != nil {
+ return fmt.Errorf("failed to decode webhook metadata: %w", err)
+ }
+
+ config := WebhookConfiguration{}
+ err = mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config)
+ if err != nil {
+ return fmt.Errorf("failed to decode webhook configuration: %w", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create Snyk client: %w", err)
+ }
+
+ err = client.DeleteWebhook(config.OrgID, webhook.ID)
+ if err != nil {
+ return fmt.Errorf("error deleting Snyk webhook: %w", err)
+ }
+
+ return nil
+}
+
+func (s *Snyk) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (s *Snyk) HandleAction(ctx core.IntegrationActionContext) error {
+ return nil
+}
+
+func (s *Snyk) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) {
+ if resourceType != "project" {
+ return []core.IntegrationResource{}, nil
+ }
+
+ orgID, err := ctx.Integration.GetConfig("organizationId")
+ if err != nil {
+ return nil, fmt.Errorf("error getting organizationId: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, fmt.Errorf("error creating client: %v", err)
+ }
+
+ projects, err := client.ListProjects(string(orgID))
+ if err != nil {
+ return nil, fmt.Errorf("error listing projects: %v", err)
+ }
+
+ resources := make([]core.IntegrationResource, 0, len(projects))
+ for _, p := range projects {
+ resources = append(resources, core.IntegrationResource{
+ Type: resourceType,
+ Name: p.Name,
+ ID: p.ID,
+ })
+ }
+
+ return resources, nil
+}
diff --git a/pkg/integrations/snyk/snyk_test.go b/pkg/integrations/snyk/snyk_test.go
new file mode 100644
index 0000000000..c4c9cef92f
--- /dev/null
+++ b/pkg/integrations/snyk/snyk_test.go
@@ -0,0 +1,173 @@
+package snyk
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSnykIntegration(t *testing.T) {
+ snyk := &Snyk{}
+
+ assert.Equal(t, "snyk", snyk.Name())
+ assert.Equal(t, "Snyk", snyk.Label())
+ assert.Equal(t, "shield", snyk.Icon())
+ assert.Equal(t, "Security workflow integration with Snyk", snyk.Description())
+
+ components := snyk.Components()
+ assert.Len(t, components, 1)
+ assert.Equal(t, "snyk.ignoreIssue", components[0].Name())
+
+ triggers := snyk.Triggers()
+ assert.Len(t, triggers, 1)
+ assert.Equal(t, "snyk.onNewIssueDetected", triggers[0].Name())
+}
+
+func TestSnykConfiguration(t *testing.T) {
+ snyk := &Snyk{}
+ configFields := snyk.Configuration()
+
+ assert.Len(t, configFields, 2)
+
+ fieldNames := make(map[string]bool)
+ for _, field := range configFields {
+ fieldNames[field.Name] = true
+ }
+
+ assert.True(t, fieldNames["apiToken"])
+ assert.True(t, fieldNames["organizationId"])
+}
+
+func Test__SnykWebhookHandler__CompareConfig(t *testing.T) {
+ s := &SnykWebhookHandler{}
+
+ testCases := []struct {
+ name string
+ configA any
+ configB any
+ expectEqual bool
+ expectError bool
+ }{
+ {
+ name: "identical configurations",
+ configA: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ ProjectID: "project-123",
+ },
+ configB: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ ProjectID: "project-123",
+ },
+ expectEqual: true,
+ expectError: false,
+ },
+ {
+ name: "different event types",
+ configA: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ },
+ configB: WebhookConfiguration{
+ EventType: "issue.resolved",
+ OrgID: "org-123",
+ },
+ expectEqual: false,
+ expectError: false,
+ },
+ {
+ name: "different org IDs",
+ configA: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ },
+ configB: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-456",
+ },
+ expectEqual: false,
+ expectError: false,
+ },
+ {
+ name: "different project IDs",
+ configA: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ ProjectID: "project-123",
+ },
+ configB: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ ProjectID: "project-456",
+ },
+ expectEqual: false,
+ expectError: false,
+ },
+ {
+ name: "all fields different",
+ configA: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ ProjectID: "project-123",
+ },
+ configB: WebhookConfiguration{
+ EventType: "issue.resolved",
+ OrgID: "org-456",
+ ProjectID: "project-456",
+ },
+ expectEqual: false,
+ expectError: false,
+ },
+ {
+ name: "comparing map representations",
+ configA: map[string]any{
+ "eventType": "issue.detected",
+ "orgId": "org-123",
+ "projectId": "project-123",
+ },
+ configB: map[string]any{
+ "eventType": "issue.detected",
+ "orgId": "org-123",
+ "projectId": "project-123",
+ },
+ expectEqual: true,
+ expectError: false,
+ },
+ {
+ name: "invalid first configuration",
+ configA: "invalid",
+ configB: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ },
+ expectEqual: false,
+ expectError: true,
+ },
+ {
+ name: "invalid second configuration",
+ configA: WebhookConfiguration{
+ EventType: "issue.detected",
+ OrgID: "org-123",
+ },
+ configB: "invalid",
+ expectEqual: false,
+ expectError: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ equal, err := s.CompareConfig(tc.configA, tc.configB)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ assert.Equal(t, tc.expectEqual, equal)
+ })
+ }
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 9286713019..3f405113c1 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -54,6 +54,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/sendgrid"
_ "github.com/superplanehq/superplane/pkg/integrations/slack"
_ "github.com/superplanehq/superplane/pkg/integrations/smtp"
+ _ "github.com/superplanehq/superplane/pkg/integrations/snyk"
_ "github.com/superplanehq/superplane/pkg/triggers/schedule"
_ "github.com/superplanehq/superplane/pkg/triggers/start"
_ "github.com/superplanehq/superplane/pkg/triggers/webhook"
diff --git a/web_src/src/assets/icons/integrations/snyk.svg b/web_src/src/assets/icons/integrations/snyk.svg
new file mode 100644
index 0000000000..5740373911
--- /dev/null
+++ b/web_src/src/assets/icons/integrations/snyk.svg
@@ -0,0 +1,3 @@
+
diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts
index 129d06bb10..51dbf915ac 100644
--- a/web_src/src/pages/workflowv2/mappers/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/index.ts
@@ -106,6 +106,11 @@ import {
triggerRenderers as claudeTriggerRenderers,
eventStateRegistry as claudeEventStateRegistry,
} from "./claude/index";
+import {
+ componentMappers as snykComponentMappers,
+ triggerRenderers as snykTriggerRenderers,
+ eventStateRegistry as snykEventStateRegistry,
+} from "./snyk/index";
import {
componentMappers as prometheusComponentMappers,
customFieldRenderers as prometheusCustomFieldRenderers,
@@ -173,6 +178,7 @@ const appMappers: Record> = {
openai: openaiComponentMappers,
circleci: circleCIComponentMappers,
claude: claudeComponentMappers,
+ snyk: snykComponentMappers,
prometheus: prometheusComponentMappers,
cursor: cursorComponentMappers,
dockerhub: dockerhubComponentMappers,
@@ -197,6 +203,7 @@ const appTriggerRenderers: Record> = {
openai: openaiTriggerRenderers,
circleci: circleCITriggerRenderers,
claude: claudeTriggerRenderers,
+ snyk: snykTriggerRenderers,
prometheus: prometheusTriggerRenderers,
cursor: cursorTriggerRenderers,
dockerhub: dockerhubTriggerRenderers,
@@ -220,6 +227,7 @@ const appEventStateRegistries: Record
circleci: circleCIEventStateRegistry,
claude: claudeEventStateRegistry,
aws: awsEventStateRegistry,
+ snyk: snykEventStateRegistry,
prometheus: prometheusEventStateRegistry,
cursor: cursorEventStateRegistry,
gitlab: gitlabEventStateRegistry,
@@ -370,7 +378,7 @@ export function getExecutionDetails(
execution: CanvasesCanvasNodeExecution,
node: ComponentsNode,
nodes?: ComponentsNode[],
-): Record | undefined {
+): Record | undefined {
const parts = componentName?.split(".");
let mapper: ComponentBaseMapper | undefined;
diff --git a/web_src/src/pages/workflowv2/mappers/snyk/ignore_issue.ts b/web_src/src/pages/workflowv2/mappers/snyk/ignore_issue.ts
new file mode 100644
index 0000000000..7763677d43
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/snyk/ignore_issue.ts
@@ -0,0 +1,116 @@
+import {
+ ComponentBaseMapper,
+ ComponentBaseContext,
+ SubtitleContext,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ OutputPayload,
+} from "../types";
+import { getState, getStateMap, getTriggerRenderer } from "..";
+import { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import snykIcon from "@/assets/icons/integrations/snyk.svg";
+import { formatTimeAgo } from "@/utils/date";
+
+interface IgnoreIssueMetadata {
+ projectId: string;
+ issueId: string;
+}
+
+interface IgnoreIssueOutput {
+ success: boolean;
+ message: string;
+ projectId: string;
+ issueId: string;
+ reason: string;
+}
+
+const COMPONENT_NAME = "snyk.ignoreIssue";
+
+export const ignoreIssueMapper: ComponentBaseMapper = {
+ props: (context: ComponentBaseContext): ComponentBaseProps => {
+ const { node } = context;
+ const metadata = node.metadata as IgnoreIssueMetadata;
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+
+ const metadataItems = [];
+
+ if (metadata?.projectId) {
+ metadataItems.push({
+ icon: "project",
+ label: metadata.projectId.substring(0, 8),
+ });
+ }
+
+ if (metadata?.issueId) {
+ metadataItems.push({
+ icon: "bug",
+ label: metadata.issueId,
+ });
+ }
+
+ return {
+ title: node.name || "Ignore Issue",
+ iconSrc: snykIcon,
+ collapsed: node.isCollapsed,
+ metadata: metadataItems,
+ eventSections: lastExecution ? buildEventSections(context.nodes, lastExecution) : undefined,
+ includeEmptyState: !lastExecution,
+ eventStateMap: getStateMap(COMPONENT_NAME),
+ };
+ },
+
+ subtitle: (context: SubtitleContext): string => {
+ const timestamp = context.execution.updatedAt || context.execution.createdAt;
+ return timestamp ? formatTimeAgo(new Date(timestamp)) : "";
+ },
+
+ getExecutionDetails: (context: ExecutionDetailsContext): Record => {
+ const { execution } = context;
+ const outputs = execution.outputs as { default?: OutputPayload[] } | undefined;
+ const output = outputs?.default?.[0]?.data as IgnoreIssueOutput | undefined;
+ const details: Record = {};
+
+ details["Started At"] = new Date(execution.createdAt).toLocaleString();
+
+ if (output) {
+ if (output.message) {
+ details["Result"] = output.message;
+ }
+
+ if (output.projectId) {
+ details["Project"] = output.projectId;
+ }
+
+ if (output.issueId) {
+ details["Issue"] = output.issueId;
+ }
+
+ if (output.reason) {
+ details["Reason"] = output.reason;
+ }
+ }
+
+ return details;
+ },
+};
+
+function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo): EventSection[] {
+ const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId);
+ const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? "");
+ const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent });
+ const subtitleTimestamp = execution.updatedAt || execution.createdAt;
+ const eventSubtitle = subtitleTimestamp ? formatTimeAgo(new Date(subtitleTimestamp)) : "";
+
+ return [
+ {
+ receivedAt: new Date(execution.createdAt!),
+ eventTitle: title,
+ eventSubtitle,
+ eventState: getState(COMPONENT_NAME)(execution),
+ eventId: execution.rootEvent!.id!,
+ },
+ ];
+}
+
+export default ignoreIssueMapper;
diff --git a/web_src/src/pages/workflowv2/mappers/snyk/index.ts b/web_src/src/pages/workflowv2/mappers/snyk/index.ts
new file mode 100644
index 0000000000..2814aac5ad
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/snyk/index.ts
@@ -0,0 +1,13 @@
+import { ComponentBaseMapper, TriggerRenderer, EventStateRegistry } from "../types";
+import { ignoreIssueMapper } from "./ignore_issue";
+import { onNewIssueDetectedTriggerRenderer } from "./on_new_issue_detected";
+
+export const componentMappers: Record = {
+ ignoreIssue: ignoreIssueMapper,
+};
+
+export const triggerRenderers: Record = {
+ onNewIssueDetected: onNewIssueDetectedTriggerRenderer,
+};
+
+export const eventStateRegistry: Record = {};
diff --git a/web_src/src/pages/workflowv2/mappers/snyk/on_new_issue_detected.ts b/web_src/src/pages/workflowv2/mappers/snyk/on_new_issue_detected.ts
new file mode 100644
index 0000000000..055104211e
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/snyk/on_new_issue_detected.ts
@@ -0,0 +1,109 @@
+import { TriggerRenderer, TriggerEventContext, TriggerRendererContext } from "../types";
+import { getColorClass } from "@/utils/colors";
+import snykIcon from "@/assets/icons/integrations/snyk.svg";
+import { TriggerProps } from "@/ui/trigger";
+
+interface OnNewIssueDetectedConfiguration {
+ projectId?: string;
+ severity?: string[];
+}
+
+interface OnNewIssueDetectedEventData {
+ issue?: {
+ id: string;
+ title: string;
+ severity: string;
+ description: string;
+ packageName: string;
+ packageVersion: string;
+ };
+ project?: {
+ id: string;
+ name: string;
+ };
+ timestamp?: string;
+}
+
+export const onNewIssueDetectedTriggerRenderer: TriggerRenderer = {
+ getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => {
+ const eventData = context.event?.data as OnNewIssueDetectedEventData;
+
+ if (eventData?.issue) {
+ const issueId = eventData.issue.id || "unknown";
+ const severity = eventData.issue.severity || "unknown";
+
+ return {
+ title: `New issue detected: ${issueId}`,
+ subtitle: `${severity.charAt(0).toUpperCase() + severity.slice(1)} severity`,
+ };
+ }
+
+ return { title: "On New Issue Detected", subtitle: "Event received" };
+ },
+
+ getRootEventValues: (context: TriggerEventContext): Record => {
+ const eventData = context.event?.data as OnNewIssueDetectedEventData;
+ const values: Record = {};
+
+ if (eventData?.issue) {
+ values["Issue ID"] = eventData.issue.id;
+ values["Issue Title"] = eventData.issue.title;
+ values["Severity"] = eventData.issue.severity;
+ values["Package"] = `${eventData.issue.packageName}@${eventData.issue.packageVersion}`;
+ }
+
+ if (eventData?.project) {
+ values["Project"] = eventData.project.name;
+ values["Project ID"] = eventData.project.id;
+ }
+
+ return values;
+ },
+
+ getTriggerProps: (context: TriggerRendererContext): TriggerProps => {
+ const { node, definition, lastEvent } = context;
+ const configuration = node.configuration as OnNewIssueDetectedConfiguration;
+ const metadata = node.metadata as { projectId?: { id: string; name: string } } | undefined;
+
+ const metadataItems = [];
+
+ if (metadata?.projectId?.name) {
+ metadataItems.push({
+ icon: "book",
+ label: metadata.projectId.name,
+ });
+ }
+
+ if (configuration?.severity && configuration.severity.length > 0) {
+ metadataItems.push({
+ icon: "alert-circle",
+ label: configuration.severity.join(", "),
+ });
+ }
+
+ const props: TriggerProps = {
+ title: node.name || "On New Issue Detected",
+ iconSrc: snykIcon,
+ iconColor: getColorClass(definition.color),
+ metadata: metadataItems,
+ };
+
+ if (lastEvent) {
+ const eventData = lastEvent.data as OnNewIssueDetectedEventData;
+ const issueId = eventData?.issue?.id || "unknown";
+ const severity = eventData?.issue?.severity || "";
+
+ props.lastEventData = {
+ title: `New issue detected: ${issueId}`,
+ subtitle: severity ? `${severity.charAt(0).toUpperCase() + severity.slice(1)} severity` : "",
+ receivedAt: new Date(lastEvent.createdAt!),
+ state: "triggered",
+ eventId: lastEvent.id!,
+ };
+ }
+
+ return props;
+ },
+};
+
+export default onNewIssueDetectedTriggerRenderer;
diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
index 38d42dd310..a9cf9f7e89 100644
--- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx
+++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
@@ -24,6 +24,7 @@ import gitlabIcon from "@/assets/icons/integrations/gitlab.svg";
import jiraIcon from "@/assets/icons/integrations/jira.svg";
import openAiIcon from "@/assets/icons/integrations/openai.svg";
import claudeIcon from "@/assets/icons/integrations/claude.svg";
+import snykIcon from "@/assets/icons/integrations/snyk.svg";
import cursorIcon from "@/assets/icons/integrations/cursor.svg";
import pagerDutyIcon from "@/assets/icons/integrations/pagerduty.svg";
import slackIcon from "@/assets/icons/integrations/slack.svg";
@@ -409,6 +410,7 @@ function CategorySection({
openai: openAiIcon,
"open-ai": openAiIcon,
claude: claudeIcon,
+ snyk: snykIcon,
cursor: cursorIcon,
pagerduty: pagerDutyIcon,
rootly: rootlyIcon,
@@ -486,6 +488,7 @@ function CategorySection({
openai: openAiIcon,
"open-ai": openAiIcon,
claude: claudeIcon,
+ snyk: snykIcon,
cursor: cursorIcon,
pagerduty: pagerDutyIcon,
rootly: rootlyIcon,
diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx
index fbfa0360e4..e3d414bd69 100644
--- a/web_src/src/ui/componentSidebar/integrationIcons.tsx
+++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx
@@ -14,6 +14,7 @@ import gitlabIcon from "@/assets/icons/integrations/gitlab.svg";
import jiraIcon from "@/assets/icons/integrations/jira.svg";
import openAiIcon from "@/assets/icons/integrations/openai.svg";
import claudeIcon from "@/assets/icons/integrations/claude.svg";
+import snykIcon from "@/assets/icons/integrations/snyk.svg";
import cursorIcon from "@/assets/icons/integrations/cursor.svg";
import pagerDutyIcon from "@/assets/icons/integrations/pagerduty.svg";
import rootlyIcon from "@/assets/icons/integrations/rootly.svg";
@@ -40,6 +41,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = {
openai: openAiIcon,
"open-ai": openAiIcon,
claude: claudeIcon,
+ snyk: snykIcon,
cursor: cursorIcon,
pagerduty: pagerDutyIcon,
rootly: rootlyIcon,
@@ -66,6 +68,7 @@ export const APP_LOGO_MAP: Record> = {
openai: openAiIcon,
"open-ai": openAiIcon,
claude: claudeIcon,
+ snyk: snykIcon,
cursor: cursorIcon,
pagerduty: pagerDutyIcon,
rootly: rootlyIcon,