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,