diff --git a/go.mod b/go.mod index dbc5384fd..63855889b 100644 --- a/go.mod +++ b/go.mod @@ -24,13 +24,15 @@ require ( ) require ( + github.com/creachadair/jrpc2 v1.3.1 github.com/go-git/go-git/v5 v5.14.0 github.com/gofrs/flock v0.12.1 github.com/mattn/go-isatty v0.0.20 github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 github.com/oapi-codegen/runtime v1.1.1 github.com/snyk/error-catalog-golang-public v0.0.0-20250218074309-307ad7b38a60 - github.com/subosito/gotenv v1.4.1 + github.com/subosito/gotenv v1.6.0 + golang.org/x/mod v0.24.0 golang.org/x/sync v0.13.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -43,6 +45,7 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cloudflare/circl v1.6.0 // indirect + github.com/creachadair/mds v0.23.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dprotaso/go-yit v0.0.0-20240618133044-5a0af90af097 // indirect @@ -107,7 +110,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect golang.org/x/crypto v0.37.0 // indirect - golang.org/x/mod v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect diff --git a/go.sum b/go.sum index f191dd89a..bad30044c 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,10 @@ github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7q github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creachadair/jrpc2 v1.3.1 h1:4B2R9050CYdhCKepbFVWQVG0/EFqRa9MuuM1Thd7tZo= +github.com/creachadair/jrpc2 v1.3.1/go.mod h1:GtMp2RXHMnrdOY8hWWlbBpjWXSVDXhuO/LMRJAtRFno= +github.com/creachadair/mds v0.23.0 h1:cANHIuKZwbfIoo/zEWA2sn+uGYjqYHuWvpoApkdjGpg= +github.com/creachadair/mds v0.23.0/go.mod h1:ArfS0vPHoLV/SzuIzoqTEZfoYmac7n9Cj8XPANHocvw= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= @@ -40,6 +44,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -264,8 +270,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= -github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -297,8 +303,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= diff --git a/pkg/product/product.go b/pkg/product/product.go new file mode 100644 index 000000000..73aee7e01 --- /dev/null +++ b/pkg/product/product.go @@ -0,0 +1,110 @@ +/* + * © 2022 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package product + +import "strings" + +type Product string +type ProductAttributes map[string]any +type FilterableIssueType string + +const ( + ProductOpenSource Product = "Snyk Open Source" + ProductCode Product = "Snyk Code" + ProductInfrastructureAsCode Product = "Snyk IaC" + ProductContainer Product = "Snyk Container" + ProductUnknown Product = "" +) + +const ( + FilterableIssueTypeOpenSource FilterableIssueType = "Open Source" + FilterableIssueTypeCodeQuality FilterableIssueType = "Code Quality" + FilterableIssueTypeCodeSecurity FilterableIssueType = "Code Security" + FilterableIssueTypeInfrastructureAsCode FilterableIssueType = "Infrastructure As Code" + FilterableIssueTypeContainer FilterableIssueType = "Container" +) + +func (p Product) ToProductCodename() string { + switch p { + case ProductOpenSource: + return "oss" + case ProductCode: + return "code" + case ProductInfrastructureAsCode: + return "iac" + case ProductContainer: + return "container" + default: + return "" + } +} + +func (p Product) ToFilterableIssueType() []FilterableIssueType { + switch p { + case ProductOpenSource: + return []FilterableIssueType{FilterableIssueTypeOpenSource} + case ProductCode: + return []FilterableIssueType{FilterableIssueTypeCodeSecurity, FilterableIssueTypeCodeQuality} + case ProductInfrastructureAsCode: + return []FilterableIssueType{FilterableIssueTypeInfrastructureAsCode} + case ProductContainer: + return []FilterableIssueType{FilterableIssueTypeContainer} + default: + return []FilterableIssueType{} + } +} + +func (f FilterableIssueType) ToProduct() Product { + switch f { + case FilterableIssueTypeOpenSource: + return ProductOpenSource + case FilterableIssueTypeCodeQuality: + return ProductCode + case FilterableIssueTypeCodeSecurity: + return ProductCode + case FilterableIssueTypeInfrastructureAsCode: + return ProductInfrastructureAsCode + case FilterableIssueTypeContainer: + return ProductContainer + default: + return ProductUnknown + } +} + +func ToProduct(productName string) Product { + switch productName { + case "oss": + return ProductOpenSource + case "code": + return ProductCode + case "iac": + return ProductInfrastructureAsCode + case "container": + return ProductContainer + default: + return ProductUnknown + } +} + +func (p Product) ToProductNamesString() string { + filterIssues := p.ToFilterableIssueType() + var productNames []string + for _, i := range filterIssues { + productNames = append(productNames, string(i)) + } + return strings.Join(productNames, ",") +} diff --git a/pkg/scanner/code/issue_wrapper.go b/pkg/scanner/code/issue_wrapper.go new file mode 100644 index 000000000..1b7bde82b --- /dev/null +++ b/pkg/scanner/code/issue_wrapper.go @@ -0,0 +1,331 @@ +// Package code provides a scanner implementation for Snyk Code +package code + +import ( + "fmt" + "net/url" + "strings" + + "github.com/snyk/go-application-framework/pkg/local_workflows/local_models" + "github.com/snyk/go-application-framework/pkg/product" + "github.com/snyk/go-application-framework/pkg/types" +) + +// FindingIssueWrapper implements types.Issue for local_models.FindingResource +type FindingIssueWrapper struct { + finding local_models.FindingResource + rule *local_models.TypesRules + lessonUrl string + isNew bool + commands []types.CommandData + codeActions []types.CodeAction + globalId string + filePath types.FilePath + systemPath string + additional types.IssueAdditionalData +} + +// FindingIssueAdditionalData implements types.IssueAdditionalData for local findings +type FindingIssueAdditionalData struct { + finding local_models.FindingResource +} + +// MarshalJSON implements json.Marshaler +func (a *FindingIssueAdditionalData) MarshalJSON() ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +// GetKey returns a unique key for this data +func (a *FindingIssueAdditionalData) GetKey() string { + return a.finding.Attributes.ReferenceId.Identifier +} + +// GetTitle returns the title of this data +func (a *FindingIssueAdditionalData) GetTitle() string { + return a.finding.Attributes.Message.Header +} + +// IsFixable returns true if this issue can be fixed +func (a *FindingIssueAdditionalData) IsFixable() bool { + return a.finding.Attributes.IsAutofixable != nil && *a.finding.Attributes.IsAutofixable +} + +// GetFilterableIssueType returns the filterable issue type +func (a *FindingIssueAdditionalData) GetFilterableIssueType() product.FilterableIssueType { + return product.FilterableIssueTypeCodeSecurity +} + +// NewFindingIssueWrapper creates a new wrapper that implements types.Issue for a FindingResource +func NewFindingIssueWrapper(finding local_models.FindingResource, rule *local_models.TypesRules, systemPath string) *FindingIssueWrapper { + // Determine file path by looking at the finding's locations + filePath := types.FilePath("") + if finding.Attributes.Locations != nil && len(*finding.Attributes.Locations) > 0 { + if (*finding.Attributes.Locations)[0].SourceLocations != nil { + filePath = types.FilePath((*finding.Attributes.Locations)[0].SourceLocations.Filepath) + } + } + + return &FindingIssueWrapper{ + finding: finding, + rule: rule, + filePath: filePath, + systemPath: systemPath, + additional: &FindingIssueAdditionalData{finding: finding}, + } +} + +// String returns a string representation of this issue +func (w *FindingIssueWrapper) String() string { + return w.finding.Attributes.Message.Header +} + +// GetID returns a unique identifier for this issue +func (w *FindingIssueWrapper) GetID() string { + return w.finding.Id.String() +} + +// GetRange returns the range of this issue +func (w *FindingIssueWrapper) GetRange() types.Range { + // Default values + startLine := 1 + endLine := 1 + startChar := 1 + endChar := 1 + + // Extract location information if available + if w.finding.Attributes.Locations != nil && len(*w.finding.Attributes.Locations) > 0 { + if (*w.finding.Attributes.Locations)[0].SourceLocations != nil { + loc := (*w.finding.Attributes.Locations)[0].SourceLocations + startLine = loc.OriginalStartLine + endLine = loc.OriginalEndLine + startChar = loc.OriginalStartColumn + endChar = loc.OriginalEndColumn + } + } + + return types.Range{ + Start: types.Position{ + Line: startLine, + Character: startChar, + }, + End: types.Position{ + Line: endLine, + Character: endChar, + }, + } +} + +// GetMessage returns the message of this issue +func (w *FindingIssueWrapper) GetMessage() string { + return w.finding.Attributes.Message.Text +} + +// GetFormattedMessage returns the formatted message of this issue +func (w *FindingIssueWrapper) GetFormattedMessage() string { + if w.finding.Attributes.Message.Markdown != "" { + return w.finding.Attributes.Message.Markdown + } + return w.finding.Attributes.Message.Text +} + +// GetAffectedFilePath returns the file path affected by this issue +func (w *FindingIssueWrapper) GetAffectedFilePath() types.FilePath { + return w.filePath +} + +// GetIsNew returns true if this issue is new +func (w *FindingIssueWrapper) GetIsNew() bool { + return w.isNew +} + +// GetIsIgnored returns true if this issue is ignored +func (w *FindingIssueWrapper) GetIsIgnored() bool { + return w.finding.Attributes.Suppression != nil +} + +// GetSeverity returns the severity of this issue +func (w *FindingIssueWrapper) GetSeverity() types.Severity { + // Map severity string to types.Severity + if w.finding.Attributes.Rating != nil { + severity := strings.ToLower(string(w.finding.Attributes.Rating.Severity.Value)) + switch severity { + case "critical": + return types.Critical + case "high": + return types.High + case "medium": + return types.Medium + case "low": + return types.Low + } + } + + // Default to medium if not specified + return types.Medium +} + +// GetIgnoreDetails returns details about why this issue is ignored +func (w *FindingIssueWrapper) GetIgnoreDetails() *types.IgnoreDetails { + if w.finding.Attributes.Suppression == nil { + return nil + } + + // Create an IgnoreDetails object with suppression information + var reason string + if w.finding.Attributes.Suppression.Justification != nil { + reason = *w.finding.Attributes.Suppression.Justification + } + + details := &types.IgnoreDetails{ + Reason: reason, + } + + return details +} + +// GetProduct returns the product this issue comes from +func (w *FindingIssueWrapper) GetProduct() product.Product { + return product.ProductCode +} + +// GetFingerprint returns a fingerprint for this issue +func (w *FindingIssueWrapper) GetFingerprint() string { + // Use the first fingerprint if available + if len(w.finding.Attributes.Fingerprint) > 0 { + // Since Fingerprint is a union type, we need to extract the value differently + // We can use the ID as a fallback + return w.GetID() + } + return w.GetID() +} + +// GetGlobalIdentity returns a global identity for this issue +func (w *FindingIssueWrapper) GetGlobalIdentity() string { + return w.globalId +} + +// GetAdditionalData returns additional data for this issue +func (w *FindingIssueWrapper) GetAdditionalData() types.IssueAdditionalData { + return w.additional +} + +// GetEcosystem returns the ecosystem this issue belongs to +func (w *FindingIssueWrapper) GetEcosystem() string { + return "" +} + +// GetCWEs returns the CWEs associated with this issue +func (w *FindingIssueWrapper) GetCWEs() []string { + if w.rule != nil { + return w.rule.Properties.Cwe + } + return nil +} + +// GetCVEs returns the CVEs associated with this issue +func (w *FindingIssueWrapper) GetCVEs() []string { + return nil +} + +// GetIssueType returns the type of this issue +func (w *FindingIssueWrapper) GetIssueType() types.IssueType { + return types.CodeSecurityVulnerability +} + +// GetLessonUrl returns a URL to a lesson about this issue +func (w *FindingIssueWrapper) GetLessonUrl() string { + return w.lessonUrl +} + +// GetIssueDescriptionURL returns a URL to a description of this issue +func (w *FindingIssueWrapper) GetIssueDescriptionURL() *url.URL { + return nil +} + +// GetCodeActions returns the code actions for this issue +func (w *FindingIssueWrapper) GetCodeActions() []types.CodeAction { + return w.codeActions +} + +// GetCodelensCommands returns the codelens commands for this issue +func (w *FindingIssueWrapper) GetCodelensCommands() []types.CommandData { + return w.commands +} + +// GetFilterableIssueType returns the filterable issue type +func (w *FindingIssueWrapper) GetFilterableIssueType() product.FilterableIssueType { + return product.FilterableIssueTypeCodeSecurity +} + +// GetRuleID returns the rule ID for this issue +func (w *FindingIssueWrapper) GetRuleID() string { + if w.finding.Attributes.ReferenceId == nil { + return "" + } + return w.finding.Attributes.ReferenceId.Identifier +} + +// GetReferences returns references for this issue +func (w *FindingIssueWrapper) GetReferences() []types.Reference { + return nil +} + +// SetCodelensCommands sets the codelens commands for this issue +func (w *FindingIssueWrapper) SetCodelensCommands(lenses []types.CommandData) { + w.commands = lenses +} + +// SetLessonUrl sets the lesson URL for this issue +func (w *FindingIssueWrapper) SetLessonUrl(url string) { + w.lessonUrl = url +} + +// SetAdditionalData sets additional data for this issue +func (w *FindingIssueWrapper) SetAdditionalData(data types.IssueAdditionalData) { + w.additional = data +} + +// SetGlobalIdentity sets the global identity for this issue +func (w *FindingIssueWrapper) SetGlobalIdentity(globalIdentity string) { + w.globalId = globalIdentity +} + +// SetIsNew sets whether this issue is new +func (w *FindingIssueWrapper) SetIsNew(isNew bool) { + w.isNew = isNew +} + +// SetCodeActions sets the code actions for this issue +func (w *FindingIssueWrapper) SetCodeActions(actions []types.CodeAction) { + w.codeActions = actions +} + +// SetRange sets the range for this issue +func (w *FindingIssueWrapper) SetRange(r types.Range) { + // This is a no-op because we derive the range from the finding's locations +} + +// ConvertLocalFindingToIssues converts a LocalFinding to a slice of types.Issue +func ConvertLocalFindingToIssues(localFinding *local_models.LocalFinding, systemPath string) []types.Issue { + var issues []types.Issue + + // Create a map of rule IDs to rules for quick lookup + ruleMap := make(map[string]*local_models.TypesRules) + for i, rule := range localFinding.Rules { + ruleMap[rule.Id] = &localFinding.Rules[i] + } + + // Convert each finding to an issue + for _, finding := range localFinding.Findings { + // Find the rule for this finding + rule := ruleMap[finding.Attributes.ReferenceId.Identifier] + + // Create an issue wrapper + issue := NewFindingIssueWrapper(finding, rule, systemPath) + + // Add to the slice of issues + issues = append(issues, issue) + } + + return issues +} diff --git a/pkg/scanner/code/issue_wrapper_test.go b/pkg/scanner/code/issue_wrapper_test.go new file mode 100644 index 000000000..42fdc0cac --- /dev/null +++ b/pkg/scanner/code/issue_wrapper_test.go @@ -0,0 +1,610 @@ +package code + +import ( + "testing" + + "github.com/google/uuid" + "github.com/snyk/go-application-framework/pkg/local_workflows/local_models" + "github.com/snyk/go-application-framework/pkg/types" + "github.com/stretchr/testify/assert" +) + +const ( + wrapperTestMessageHeader = "Test Security Issue" + wrapperTestMessageText = "This is a test security issue" + wrapperTestComponentName = "test-component" + wrapperTestScanType = "sast" + wrapperTestRuleId = "test-rule-id" +) + +func TestFindingIssueWrapper_Implementation(t *testing.T) { + // Create a sample finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + + refId := local_models.TypesReferenceId{ + Identifier: wrapperTestRuleId, + Index: 0, + } + + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Create a sample location + sourceLocation := local_models.IoSnykReactiveFindingSourceLocation{ + Filepath: "/path/to/file.js", + OriginalStartLine: 10, + OriginalEndLine: 10, + OriginalStartColumn: 5, + OriginalEndColumn: 20, + } + + location := local_models.IoSnykReactiveFindingLocation{ + SourceLocations: &sourceLocation, + } + + // Create a finding resource + locations := []local_models.IoSnykReactiveFindingLocation{location} + isAutofixable := false + finding := local_models.FindingResource{ + Id: findingId, + Type: "finding", + Attributes: local_models.TypesFindingAttributes{ + Locations: &locations, + Message: msg, + ReferenceId: &refId, + Component: component, + IsAutofixable: &isAutofixable, + }, + } + + // Create a sample rule + rule := local_models.TypesRules{ + Id: wrapperTestRuleId, + Name: "Test Rule", + ShortDescription: struct { + Text string `json:"text"` + }{ + Text: "Test Rule Description", + }, + Properties: struct { + Categories []string `json:"categories"` + Cwe []string `json:"cwe"` + ExampleCommitDescriptions []string `json:"exampleCommitDescriptions"` + ExampleCommitFixes []local_models.TypesExampleCommitFix `json:"exampleCommitFixes"` + Precision string `json:"precision"` + RepoDatasetSize int `json:"repoDatasetSize"` + Tags []string `json:"tags"` + }{ + Cwe: []string{"CWE-79"}, + }, + } + + // Create the wrapper + wrapper := NewFindingIssueWrapper(finding, &rule, "/project") + + // Test the wrapper methods + assert.Equal(t, wrapperTestMessageHeader, wrapper.String(), "Expected string representation to match the test message header") + assert.Equal(t, findingId.String(), wrapper.GetID(), "Expected ID to match the finding ID") + assert.Equal(t, "/path/to/file.js", string(wrapper.GetAffectedFilePath()), "Expected affected file path to match the test file path") + assert.Equal(t, wrapperTestMessageText, wrapper.GetMessage(), "Expected message to match the test message text") + assert.Equal(t, types.CodeSecurityVulnerability, wrapper.GetIssueType(), "Expected issue type to be CodeSecurityVulnerability") + assert.Equal(t, wrapperTestRuleId, wrapper.GetRuleID(), "Expected rule ID to match the test rule ID") + assert.Contains(t, wrapper.GetCWEs(), "CWE-79", "Expected CWEs to contain CWE-79") + + // Test the range + expectedRange := types.Range{ + Start: types.Position{Line: 10, Character: 5}, + End: types.Position{Line: 10, Character: 20}, + } + assert.Equal(t, expectedRange, wrapper.GetRange(), "Expected range to match the test range") +} + +func TestConvertLocalFindingToIssues(t *testing.T) { + // Create sample finding and rule + findingId := uuid.New() + refId := local_models.TypesReferenceId{ + Identifier: wrapperTestRuleId, + Index: 0, + } + sourceLocation := local_models.IoSnykReactiveFindingSourceLocation{ + Filepath: "/path/to/file.js", + OriginalStartLine: 10, + OriginalEndLine: 10, + OriginalStartColumn: 5, + OriginalEndColumn: 20, + } + + location := local_models.IoSnykReactiveFindingLocation{ + SourceLocations: &sourceLocation, + } + locations := []local_models.IoSnykReactiveFindingLocation{location} + + finding := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + }, + ReferenceId: &refId, + Locations: &locations, + }, + } + + rule := local_models.TypesRules{ + Id: wrapperTestRuleId, + Name: "Test Rule", + } + + // Create a LocalFinding with the sample finding and rule + localFinding := &local_models.LocalFinding{ + Findings: []local_models.FindingResource{finding}, + Rules: []local_models.TypesRules{rule}, + } + + // Convert to issues + issues := ConvertLocalFindingToIssues(localFinding, "/project") + + // Verify conversion + assert.Len(t, issues, 1, "Expected one issue to be converted") + assert.Equal(t, findingId.String(), issues[0].GetID(), "Expected ID to match the finding ID") + assert.Equal(t, wrapperTestRuleId, issues[0].GetRuleID(), "Expected rule ID to match the test rule ID") + assert.Equal(t, wrapperTestMessageHeader, issues[0].String(), "Expected string representation to match the test message header") +} + +func TestFindingIssueWrapper_GetIgnoreDetails(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Case 1: Finding with no suppression + finding1 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper1 := NewFindingIssueWrapper(finding1, nil, "/project") + assert.Nil(t, wrapper1.GetIgnoreDetails(), "Expected nil ignore details when no suppression exists") + + // Case 2: Finding with suppression but nil justification + justification := "This is a test justification" + suppression := local_models.TypesSuppression{ + Kind: "ignored", + } + finding2 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + Suppression: &suppression, + }, + } + wrapper2 := NewFindingIssueWrapper(finding2, nil, "/project") + ignoreDetails2 := wrapper2.GetIgnoreDetails() + assert.NotNil(t, ignoreDetails2, "Expected non-nil ignore details") + assert.Empty(t, ignoreDetails2.Reason, "Expected empty reason when justification is nil") + + // Case 3: Finding with suppression and non-nil justification + suppression3 := local_models.TypesSuppression{ + Kind: "ignored", + Justification: &justification, + } + finding3 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + Suppression: &suppression3, + }, + } + wrapper3 := NewFindingIssueWrapper(finding3, nil, "/project") + ignoreDetails3 := wrapper3.GetIgnoreDetails() + assert.NotNil(t, ignoreDetails3, "Expected non-nil ignore details") + assert.Equal(t, justification, ignoreDetails3.Reason, "Expected justification to match") +} + +func TestFindingIssueWrapper_GetFingerprint(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Case 1: Finding with no fingerprints + finding1 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper1 := NewFindingIssueWrapper(finding1, nil, "/project") + assert.Equal(t, findingId.String(), wrapper1.GetFingerprint(), "Expected ID as fallback when no fingerprint exists") +} + +func TestFindingIssueWrapper_NilReferenceHandling(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Case 1: Finding with nil locations + finding1 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper1 := NewFindingIssueWrapper(finding1, nil, "/project") + + // Test GetAffectedFilePath with nil locations + assert.Equal(t, types.FilePath(""), wrapper1.GetAffectedFilePath(), "Expected empty file path with nil locations") + + // Test GetRange with nil locations + defaultRange := types.Range{ + Start: types.Position{Line: 1, Character: 1}, + End: types.Position{Line: 1, Character: 1}, + } + assert.Equal(t, defaultRange, wrapper1.GetRange(), "Expected default range with nil locations") + + // Case 2: Finding with empty locations array + emptyLocations := []local_models.IoSnykReactiveFindingLocation{} + finding2 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + Locations: &emptyLocations, + }, + } + wrapper2 := NewFindingIssueWrapper(finding2, nil, "/project") + assert.Equal(t, types.FilePath(""), wrapper2.GetAffectedFilePath(), "Expected empty file path with empty locations") + + // Case 3: Finding with location but nil SourceLocations + location3 := local_models.IoSnykReactiveFindingLocation{} + locations3 := []local_models.IoSnykReactiveFindingLocation{location3} + finding3 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + Locations: &locations3, + }, + } + wrapper3 := NewFindingIssueWrapper(finding3, nil, "/project") + assert.Equal(t, types.FilePath(""), wrapper3.GetAffectedFilePath(), "Expected empty file path with nil SourceLocations") + + // Case 4: Finding with nil ReferenceId + finding4 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper4 := NewFindingIssueWrapper(finding4, nil, "/project") + assert.Equal(t, "", wrapper4.GetRuleID(), "Expected empty rule ID with nil ReferenceId") +} + +func TestFindingIssueWrapper_UnsupportedFeatures(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Create a finding resource + finding := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper := NewFindingIssueWrapper(finding, nil, "/project") + + // Test methods that return nil but don't panic + assert.Nil(t, wrapper.GetReferences(), "Expected nil references") + assert.Nil(t, wrapper.GetCVEs(), "Expected nil CVEs") + assert.Nil(t, wrapper.GetIssueDescriptionURL(), "Expected nil issue description URL") + assert.Equal(t, "", wrapper.GetEcosystem(), "Expected empty ecosystem string") + assert.Nil(t, wrapper.GetCodeActions(), "Expected nil code actions") + assert.False(t, wrapper.GetIsNew(), "Expected GetIsNew to return false by default") + + // Test setter methods (should not panic) + wrapper.SetCodeActions(nil) + wrapper.SetLessonUrl("https://example.com") + wrapper.SetGlobalIdentity("test-global-id") + assert.Equal(t, "https://example.com", wrapper.GetLessonUrl(), "Expected lesson URL to be set") + assert.Equal(t, "test-global-id", wrapper.GetGlobalIdentity(), "Expected global identity to be set") + + // Test SetIsNew + wrapper.SetIsNew(true) + assert.True(t, wrapper.GetIsNew(), "Expected GetIsNew to return true after setting") +} + +func TestFindingIssueWrapper_GetSeverity(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Test all possible severity levels + severityTests := []struct { + Level string + ExpectedSeverity types.Severity + Description string + }{ + {"critical", types.Critical, "Critical severity"}, + {"high", types.High, "High severity"}, + {"medium", types.Medium, "Medium severity"}, + {"low", types.Low, "Low severity"}, + {"info", types.Medium, "Info mapped to Medium severity"}, + {"unknown", types.Medium, "Unknown mapped to Medium severity"}, + {"", types.Medium, "Empty severity mapped to Medium"}, + {"nonsense", types.Medium, "Invalid severity mapped to Medium"}, + } + + for _, test := range severityTests { + t.Run(test.Description, func(t *testing.T) { + // Create rating with the test level + rating := local_models.TypesFindingRating{} + + // Set the severity value directly into the struct + severityValue := local_models.TypesFindingRatingSeverityValue(test.Level) + rating.Severity.Value = severityValue + + // Create a finding with this rating + finding := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + Rating: &rating, + }, + } + + // Create the wrapper + wrapper := NewFindingIssueWrapper(finding, nil, "/project") + + // Test the severity mapping + assert.Equal(t, test.ExpectedSeverity, wrapper.GetSeverity(), + "Severity level '%s' did not map to expected severity", test.Level) + }) + } + + // Test nil rating + t.Run("Nil rating", func(t *testing.T) { + finding := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper := NewFindingIssueWrapper(finding, nil, "/project") + assert.Equal(t, types.Medium, wrapper.GetSeverity(), "Nil rating should map to Medium severity") + }) +} + +func TestFindingIssueWrapper_GetCWEs(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Case 1: Finding with no rule (nil rule) + finding1 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper1 := NewFindingIssueWrapper(finding1, nil, "/project") + assert.Empty(t, wrapper1.GetCWEs(), "Expected empty CWEs with nil rule") + + // Case 2: Finding with rule but no CWEs + rule2 := local_models.TypesRules{ + Id: wrapperTestRuleId, + Name: "Test Rule", + Properties: struct { + Categories []string `json:"categories"` + Cwe []string `json:"cwe"` + ExampleCommitDescriptions []string `json:"exampleCommitDescriptions"` + ExampleCommitFixes []local_models.TypesExampleCommitFix `json:"exampleCommitFixes"` + Precision string `json:"precision"` + RepoDatasetSize int `json:"repoDatasetSize"` + Tags []string `json:"tags"` + }{}, + } + finding2 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper2 := NewFindingIssueWrapper(finding2, &rule2, "/project") + assert.Empty(t, wrapper2.GetCWEs(), "Expected empty CWEs with empty rule.Properties.Cwe") + + // Case 3: Finding with rule and multiple CWEs + rule3 := local_models.TypesRules{ + Id: wrapperTestRuleId, + Name: "Test Rule", + Properties: struct { + Categories []string `json:"categories"` + Cwe []string `json:"cwe"` + ExampleCommitDescriptions []string `json:"exampleCommitDescriptions"` + ExampleCommitFixes []local_models.TypesExampleCommitFix `json:"exampleCommitFixes"` + Precision string `json:"precision"` + RepoDatasetSize int `json:"repoDatasetSize"` + Tags []string `json:"tags"` + }{ + Cwe: []string{"CWE-79", "CWE-22", "CWE-89"}, + }, + } + finding3 := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper3 := NewFindingIssueWrapper(finding3, &rule3, "/project") + + expectedCWEs := []string{"CWE-79", "CWE-22", "CWE-89"} + actualCWEs := wrapper3.GetCWEs() + assert.Equal(t, len(expectedCWEs), len(actualCWEs), "Expected same number of CWEs") + for i, cwe := range expectedCWEs { + assert.Equal(t, cwe, actualCWEs[i], "Expected CWE %s at position %d", cwe, i) + } +} + +func TestFindingIssueWrapper_SetMethods(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Create a finding resource + finding := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + wrapper := NewFindingIssueWrapper(finding, nil, "/project") + + // Test Setting Additional Data + newData := &FindingIssueAdditionalData{ + finding: local_models.FindingResource{ + Attributes: local_models.TypesFindingAttributes{ + ReferenceId: &local_models.TypesReferenceId{ + Identifier: wrapperTestRuleId, + }, + Message: local_models.TypesFindingMessage{ + Header: "Test Header", + }, + }, + }, + } + wrapper.SetAdditionalData(newData) + actualData := wrapper.GetAdditionalData() + assert.IsType(t, &FindingIssueAdditionalData{}, actualData, "Expected FindingIssueAdditionalData type") + assert.Equal(t, wrapperTestRuleId, actualData.(*FindingIssueAdditionalData).GetKey(), "Expected GetKey to return the test ID") + assert.Equal(t, "Test Header", actualData.(*FindingIssueAdditionalData).GetTitle(), "Expected GetTitle to return the test header") + + // Test SetLessonUrl + assert.Empty(t, wrapper.GetLessonUrl(), "Expected empty lesson URL initially") + wrapper.SetLessonUrl("https://example.com/lesson") + assert.Equal(t, "https://example.com/lesson", wrapper.GetLessonUrl(), "Expected lesson URL to be set") + + // Test SetGlobalIdentity + assert.Empty(t, wrapper.GetGlobalIdentity(), "Expected empty global identity initially") + wrapper.SetGlobalIdentity("test-global-id") + assert.Equal(t, "test-global-id", wrapper.GetGlobalIdentity(), "Expected global identity to be set") + + // Test SetIsNew + assert.False(t, wrapper.GetIsNew(), "Expected GetIsNew to return false by default") + wrapper.SetIsNew(true) + assert.True(t, wrapper.GetIsNew(), "Expected GetIsNew to return true after setting") +} + +func TestFindingIssueWrapper_UntestableMethods(t *testing.T) { + // Create a basic finding resource + findingId := uuid.New() + msg := local_models.TypesFindingMessage{ + Header: wrapperTestMessageHeader, + Text: wrapperTestMessageText, + } + component := local_models.TypesComponent{ + Name: wrapperTestComponentName, + ScanType: wrapperTestScanType, + } + + // Create a finding resource + finding := local_models.FindingResource{ + Id: findingId, + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + Component: component, + }, + } + + // Create the wrapper + wrapper := NewFindingIssueWrapper(finding, nil, "/project") + + // Test GetFormattedMessage (it returns the message text) + assert.Equal(t, wrapperTestMessageText, wrapper.GetFormattedMessage(), "Expected GetFormattedMessage to return the message text") + + // Test GetIsIgnored (it returns false as not implemented) + assert.False(t, wrapper.GetIsIgnored(), "Expected GetIsIgnored to return false") + + // Test GetCodelensCommands (it returns nil as not implemented) + assert.Nil(t, wrapper.GetCodelensCommands(), "Expected GetCodelensCommands to return nil") + + // Test SetCodelensCommands (passing nil as we don't have access to the actual type) + wrapper.SetCodelensCommands(nil) + assert.Nil(t, wrapper.GetCodelensCommands(), "Expected GetCodelensCommands to still return nil after setting") + + // Test SetRange (it's a no-op) + initialRange := wrapper.GetRange() + newRange := types.Range{ + Start: types.Position{Line: 100, Character: 100}, + End: types.Position{Line: 200, Character: 200}, + } + wrapper.SetRange(newRange) + assert.Equal(t, initialRange, wrapper.GetRange(), "Expected range to remain unchanged after SetRange") +} diff --git a/pkg/scanner/code/scanner.go b/pkg/scanner/code/scanner.go new file mode 100644 index 000000000..b5dab633d --- /dev/null +++ b/pkg/scanner/code/scanner.go @@ -0,0 +1,215 @@ +// Package code provides a scanner implementation for Snyk Code +package code + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/snyk/code-client-go/sarif" + "github.com/snyk/go-application-framework/pkg/configuration" + localworkflows "github.com/snyk/go-application-framework/pkg/local_workflows" + "github.com/snyk/go-application-framework/pkg/local_workflows/json_schemas" + "github.com/snyk/go-application-framework/pkg/local_workflows/local_models" + "github.com/snyk/go-application-framework/pkg/product" + "github.com/snyk/go-application-framework/pkg/types" + "github.com/snyk/go-application-framework/pkg/workflow" +) + +var _ types.Scanner = (*Scanner)(nil) +var _ types.IssueProvider = (*Scanner)(nil) +var _ types.CacheProvider = (*Scanner)(nil) + +// Scanner implements the types.Scanner interface for Snyk Code +type Scanner struct { + engine workflow.Engine + product product.Product +} + +func (s *Scanner) IsProviderFor(issueType product.FilterableIssueType) bool { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) Clear() { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) ClearIssues(path types.FilePath) { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) RegisterCacheRemovalHandler(handler func(path types.FilePath)) { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) IssuesForFile(path types.FilePath) []types.Issue { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) IssuesForRange(path types.FilePath, r types.Range) []types.Issue { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) Issue(key string) types.Issue { + //TODO implement me + panic("implement me") +} + +func (s *Scanner) Issues() types.IssuesByFile { + //TODO implement me + panic("implement me") +} + +// New creates a new Scanner instance +func New(engine workflow.Engine) *Scanner { + return &Scanner{ + engine: engine, + product: product.ProductCode, + } +} + +// Scan implements the types.Scanner interface +// It runs the code workflow and processes the results +func (s *Scanner) Scan(ctx context.Context, path types.FilePath, processResults types.ScanResultProcessor, folderPath types.FilePath) { + // Create a timer to measure scan duration + start := time.Now() + + // Configure the workflow invocation + config := s.engine.GetConfiguration().Clone() + config.Set(configuration.INPUT_DIRECTORY, string(path)) + + // Set up scan data with default values + scanData := types.ScanData{ + Product: s.product, + Path: path, + IsDeltaScan: false, + SendAnalytics: true, + UpdateGlobalCache: true, + } + + // Invoke the code workflow + results, err := s.engine.InvokeWithConfig(localworkflows.WORKFLOWID_CODE, config) + + // Calculate scan duration + scanData.DurationMs = time.Since(start) + scanData.TimestampFinished = time.Now() + + // Handle errors from workflow invocation + if err != nil { + scanData.Err = fmt.Errorf("workflow invocation failed: %w", err) + processResults(ctx, scanData) + return + } + + // Check if we have workflow results + if len(results) == 0 { + // No results - this is acceptable as it might mean no issues were found + processResults(ctx, scanData) + return + } + + // Process results data + for _, data := range results { + contentType := data.GetContentType() + + switch contentType { + // Handle findings data from the code workflow + case "application/vnd.code.finding+json": + payload := data.GetPayload() + if payload == nil { + scanData.Err = fmt.Errorf("nil payload for content type: %s", contentType) + processResults(ctx, scanData) + return + } + + // Parse the payload as a LocalFinding + localFinding, ok := payload.(local_models.LocalFinding) + if !ok { + scanData.Err = fmt.Errorf("unexpected payload type for content type %s", contentType) + processResults(ctx, scanData) + return + } + + // Convert local findings to issues + issues := ConvertLocalFindingToIssues(&localFinding, string(path)) + + // Append the issues to the scan data + scanData.Issues = append(scanData.Issues, issues...) + + // Handle SARIF data + case "application/sarif+json": + // For backward compatibility, handle SARIF directly if needed + payload := data.GetPayload() + if payload == nil { + scanData.Err = fmt.Errorf("nil payload for content type: %s", contentType) + processResults(ctx, scanData) + return + } + + var sarifJson string + + // Handle different payload types + switch v := payload.(type) { + case string: + sarifJson = v + case []byte: + sarifJson = string(v) + default: + scanData.Err = fmt.Errorf("unexpected payload type for SARIF data: %T", payload) + continue + } + + // Log a warning since we should be using findings data + logger := s.engine.GetLogger() + // Only use logger if it's available (added for testing) + if logger != nil && !isNilInterface(logger) { + logger.Warn().Msg("Using SARIF instead of findings data - converting to LocalFinding model") + } + + // Try to parse the payload as JSON + var sarifDoc sarif.SarifDocument + if err := json.Unmarshal([]byte(sarifJson), &sarifDoc); err != nil { + scanData.Err = fmt.Errorf("failed to parse SARIF: %w", err) + processResults(ctx, scanData) + return + } + + // Create an empty test summary + testSummary := &json_schemas.TestSummary{ + Path: string(path), + Type: "sast", + } + + // Convert the SARIF document to a LocalFinding + localFinding, err := local_models.TransformToLocalFindingModelFromSarif(&sarifDoc, testSummary) + if err != nil { + scanData.Err = fmt.Errorf("failed to transform SARIF: %w", err) + processResults(ctx, scanData) + return + } + + // Convert LocalFinding to issues + issues := ConvertLocalFindingToIssues(&localFinding, string(path)) + + // Append the issues to the scan data + scanData.Issues = append(scanData.Issues, issues...) + } + } + + // Process the results + processResults(ctx, scanData) +} + +// isNilInterface safely checks if an interface is nil by examining both the interface itself +// and the value it points to, which is necessary for proper nil checking of interface values +func isNilInterface(i interface{}) bool { + return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) +} diff --git a/pkg/scanner/code/scanner_test.go b/pkg/scanner/code/scanner_test.go new file mode 100644 index 000000000..9dd16d20f --- /dev/null +++ b/pkg/scanner/code/scanner_test.go @@ -0,0 +1,961 @@ +package code + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/local_workflows" + "github.com/snyk/go-application-framework/pkg/local_workflows/local_models" + "github.com/snyk/go-application-framework/pkg/mocks" + "github.com/snyk/go-application-framework/pkg/product" + "github.com/snyk/go-application-framework/pkg/types" + "github.com/snyk/go-application-framework/pkg/workflow" + "github.com/stretchr/testify/assert" +) + +// Constants for testing +const ( + testPath = "/test/path" + testMessageText = "Test message" + testMessageHeader = "Test Issue" + testScanType = "sast" + testComponentName = "test-component" +) + +func TestScanner_Scan_SARIF(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock SARIF response in the workflow.Data + sarifJson := `{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "Snyk Code", + "semanticVersion": "1.0.0", + "rules": [{ + "id": "javascript/PathTraversal", + "name": "Path Traversal", + "shortDescription": { + "text": "Path Traversal" + }, + "help": { + "text": "Vulnerability that allows attackers to access files" + }, + "properties": { + "tags": ["security"], + "precision": "high" + } + }] + } + }, + "results": [{ + "ruleId": "javascript/PathTraversal", + "level": "error", + "message": { + "text": "Path traversal vulnerability detected" + }, + "locations": [{ + "physicalLocation": { + "artifactLocation": { + "uri": "/test/path/file.js" + }, + "region": { + "startLine": 10, + "startColumn": 5, + "endLine": 10, + "endColumn": 20 + } + } + }] + }] + }] + }` + + // Set up the mock data payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/sarif+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(sarifJson).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path"), scanData.Path, "Expected path to be /test/path") + assert.Nil(t, scanData.Err, "Expected no error") + assert.False(t, scanData.IsDeltaScan, "Expected IsDeltaScan to be false") + assert.True(t, scanData.SendAnalytics, "Expected SendAnalytics to be true") + assert.True(t, scanData.UpdateGlobalCache, "Expected UpdateGlobalCache to be true") + assert.NotZero(t, scanData.DurationMs, "Expected DurationMs to be non-zero") + assert.NotZero(t, scanData.TimestampFinished, "Expected TimestampFinished to be non-zero") + + // Verify issues were created + assert.NotEmpty(t, scanData.Issues, "Expected issues to be populated") + assert.Len(t, scanData.Issues, 1, "Expected exactly 1 issue") + + // Verify the issue properties + issue := scanData.Issues[0] + assert.Equal(t, "javascript/PathTraversal", issue.GetRuleID(), "Expected rule ID to match") + assert.Equal(t, "Path traversal vulnerability detected", issue.GetMessage(), "Expected message to match") + assert.Equal(t, types.High, issue.GetSeverity(), "Expected severity to be High") + assert.Equal(t, types.CodeSecurityVulnerability, issue.GetIssueType(), "Expected issue type to be CodeSecurityVulnerability") + assert.Equal(t, product.FilterableIssueTypeCodeSecurity, issue.GetFilterableIssueType(), "Expected filterable issue type to be CodeSecurity") + assert.Equal(t, product.ProductCode, issue.GetProduct(), "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path/file.js"), issue.GetAffectedFilePath(), "Expected affected file path to match") + + // Verify the issue range + expectedRange := types.Range{ + Start: types.Position{Line: 10, Character: 5}, + End: types.Position{Line: 10, Character: 20}, + } + assert.Equal(t, expectedRange, issue.GetRange(), "Expected range to match") +} + +func TestScanner_Scan_LocalFinding(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Create a sample local finding + localFinding := local_models.LocalFinding{ + Summary: local_models.TypesFindingsSummary{ + Path: testPath, + Type: testScanType, + }, + Rules: []local_models.TypesRules{ + { + Id: "javascript/TestRule", + Name: "Test Rule", + ShortDescription: struct { + Text string `json:"text"` + }{ + Text: "Test Rule Description", + }, + DefaultConfiguration: struct { + Level string `json:"level"` + }{ + Level: "error", + }, + }, + }, + } + + // Create a finding resource + sourceLocation := local_models.IoSnykReactiveFindingSourceLocation{ + Filepath: "/test/path/file.js", + OriginalStartLine: 5, + OriginalEndLine: 5, + OriginalStartColumn: 10, + OriginalEndColumn: 20, + } + location := local_models.IoSnykReactiveFindingLocation{ + SourceLocations: &sourceLocation, + } + locations := []local_models.IoSnykReactiveFindingLocation{location} + + // Add a finding to the local finding + msg := local_models.TypesFindingMessage{ + Header: testMessageHeader, + Text: testMessageText, + } + component := local_models.TypesComponent{ + Name: testComponentName, + ScanType: testScanType, + } + findingResource := local_models.FindingResource{ + Attributes: local_models.TypesFindingAttributes{ + Message: msg, + ReferenceId: &local_models.TypesReferenceId{ + Identifier: "javascript/TestRule", + Index: 0, + }, + Locations: &locations, + Component: component, + }, + } + localFinding.Findings = []local_models.FindingResource{findingResource} + + // Set up the mock data payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/vnd.code.finding+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(localFinding).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path"), scanData.Path, "Expected path to be /test/path") + assert.Nil(t, scanData.Err, "Expected no error") + + // Verify issues were created + assert.NotEmpty(t, scanData.Issues, "Expected issues to be populated") + assert.Len(t, scanData.Issues, 1, "Expected exactly 1 issue") + + // Verify the issue properties + issue := scanData.Issues[0] + assert.Equal(t, "javascript/TestRule", issue.GetRuleID(), "Expected rule ID to match") + assert.Equal(t, testMessageText, issue.GetMessage(), "Expected message to match") + assert.Equal(t, types.FilePath("/test/path/file.js"), issue.GetAffectedFilePath(), "Expected affected file path to match") + + // Verify the issue range + expectedRange := types.Range{ + Start: types.Position{Line: 5, Character: 10}, + End: types.Position{Line: 5, Character: 20}, + } + assert.Equal(t, expectedRange, issue.GetRange(), "Expected range to match") +} + +func TestScanner_Scan_InvalidSARIF(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock invalid SARIF response + invalidSarifJson := `{"invalid": "json` + + // Set up the mock data payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/sarif+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(invalidSarifJson).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify error is present and no issues were created + assert.NotNil(t, scanData.Err, "Expected error to be populated") + assert.Contains(t, scanData.Err.Error(), "failed to parse SARIF", "Expected SARIF parsing error") + assert.Empty(t, scanData.Issues, "Expected no issues to be populated due to error") +} + +func TestScanner_ScanWithError(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to return an error + expectedError := errors.New("workflow error") + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return(nil, expectedError) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test"), scanData.Path, "Expected path to match input path") + assert.NotNil(t, scanData.Err, "Expected an error to be present") + assert.Contains(t, scanData.Err.Error(), "workflow invocation failed", "Error should indicate workflow invocation failure") + assert.Contains(t, scanData.Err.Error(), "workflow error", "Error should contain the original error message") + assert.Empty(t, scanData.Issues, "Expected no issues on error") + assert.NotZero(t, scanData.DurationMs, "Expected DurationMs to be non-zero") + assert.NotZero(t, scanData.TimestampFinished, "Expected TimestampFinished to be non-zero") +} + +func TestScanner_Scan_InvalidPayloadType(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Set up the mock data payload with an invalid type (int is not supported) + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/vnd.code.finding+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(123).AnyTimes(), // Integer is an invalid payload type + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify error is present and no issues were created + assert.NotNil(t, scanData.Err, "Expected error to be populated") + assert.Contains(t, scanData.Err.Error(), "unexpected payload type", "Expected invalid payload type error") + assert.Empty(t, scanData.Issues, "Expected no issues to be populated due to error") +} + +func TestScanner_Scan_EmptyResults(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to return empty results (not nil, just empty) + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path"), scanData.Path, "Expected path to be /test/path") + + // Empty results are now acceptable - this design change ensures we handle no findings correctly + assert.Nil(t, scanData.Err, "Expected no error with empty results") + assert.Empty(t, scanData.Issues, "Expected no issues with empty results") +} + +func TestScanner_Scan_MultipleResults(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData1 := mocks.NewMockData(ctrl) + mockData2 := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Create sample findings for two different content types + // Sample SARIF data for first result + sarifJson := `{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "Snyk Code", + "rules": [{ + "id": "javascript/PathTraversal1", + "shortDescription": { "text": "Path Traversal 1" }, + "help": { "text": "Help 1" } + }] + } + }, + "results": [{ + "ruleId": "javascript/PathTraversal1", + "level": "error", + "message": { "text": "Issue 1" }, + "locations": [{ + "physicalLocation": { + "artifactLocation": { "uri": "/test/path/file1.js" }, + "region": { "startLine": 10, "startColumn": 5, "endLine": 10, "endColumn": 20 } + } + }] + }] + }] + }` + + // Sample LocalFinding for second result + sourceLocation := local_models.IoSnykReactiveFindingSourceLocation{ + Filepath: "/test/path/file2.js", + OriginalStartLine: 5, + OriginalEndLine: 5, + OriginalStartColumn: 10, + OriginalEndColumn: 20, + } + location := local_models.IoSnykReactiveFindingLocation{ + SourceLocations: &sourceLocation, + } + locations := []local_models.IoSnykReactiveFindingLocation{location} + + findingResource := local_models.FindingResource{ + Attributes: local_models.TypesFindingAttributes{ + Message: local_models.TypesFindingMessage{ + Header: "Test Finding 2", + Text: "This is issue 2", + }, + ReferenceId: &local_models.TypesReferenceId{ + Identifier: "javascript/TestRule2", + Index: 0, + }, + Locations: &locations, + Component: local_models.TypesComponent{ + Name: "test-component", + ScanType: "sast", + }, + }, + } + localFinding := local_models.LocalFinding{ + Summary: local_models.TypesFindingsSummary{ + Path: "/test/path", + Type: "sast", + }, + Rules: []local_models.TypesRules{ + { + Id: "javascript/TestRule2", + Name: "Test Rule 2", + ShortDescription: struct { + Text string `json:"text"` + }{ + Text: "Test Rule 2 Description", + }, + }, + }, + Findings: []local_models.FindingResource{findingResource}, + } + + // Set up the mock data payloads + gomock.InOrder( + mockData1.EXPECT().GetContentType().Return("application/sarif+json").AnyTimes(), + mockData1.EXPECT().GetPayload().Return(sarifJson).AnyTimes(), + mockData2.EXPECT().GetContentType().Return("application/vnd.code.finding+json").AnyTimes(), + mockData2.EXPECT().GetPayload().Return(localFinding).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to return multiple data items + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData1, mockData2}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify that we have scan data with issues from both sources + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path"), scanData.Path, "Expected path to be /test/path") + assert.Nil(t, scanData.Err, "Expected no error") + + // Verify that we have issues from both sources + assert.Len(t, scanData.Issues, 2, "Expected 2 issues (one from each source)") + + // Verify details of each issue + // Check if we have issues with the expected rule IDs + ruleIDs := []string{"javascript/PathTraversal1", "javascript/TestRule2"} + found := make(map[string]bool) + for _, issue := range scanData.Issues { + ruleID := issue.GetRuleID() + found[ruleID] = true + + switch ruleID { + case "javascript/PathTraversal1": + assert.Equal(t, "Issue 1", issue.GetMessage(), "Expected correct message for first issue") + assert.Equal(t, types.FilePath("/test/path/file1.js"), issue.GetAffectedFilePath(), "Expected correct file for first issue") + case "javascript/TestRule2": + assert.Equal(t, "This is issue 2", issue.GetMessage(), "Expected correct message for second issue") + assert.Equal(t, types.FilePath("/test/path/file2.js"), issue.GetAffectedFilePath(), "Expected correct file for second issue") + } + } + + // Verify we found issues with both rule IDs + for _, ruleID := range ruleIDs { + assert.True(t, found[ruleID], "Expected to find issue with rule ID %s", ruleID) + } +} + +func TestScanner_Scan_NilPayload(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Set up the mock data payload with nil payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/vnd.code.finding+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(nil).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to return data with nil payload + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path"), scanData.Path, "Expected path to match input path") + + // We now expect an error for nil payloads - this is a design improvement for error handling + assert.NotNil(t, scanData.Err, "Expected error with nil payload") + assert.Contains(t, scanData.Err.Error(), "nil payload for content type", "Error should indicate nil payload") + assert.Empty(t, scanData.Issues, "Expected no issues with nil payload") +} + +func TestScanner_Scan_InvalidSARIFStructure(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock structurally invalid SARIF (valid JSON but missing required fields) + invalidSarifJson := `{"version": "2.1.0", "$schema": "schema", "runs": [{}]}` // Missing tool driver + + // Set up the mock data payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/sarif+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(invalidSarifJson).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // The conversion might succeed, but there should be 0 issues + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, types.FilePath("/test/path"), scanData.Path, "Expected path to be /test/path") + + // Check that we either have an error OR we have 0 issues + if scanData.Err == nil { + assert.Empty(t, scanData.Issues, "Expected no issues with invalid SARIF structure") + } +} + +func TestScanner_Scan_WithLogger(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock SARIF response + sarifJson := `{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{}] + }` + + // Set up the mock data payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/sarif+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(sarifJson).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + + // Verify that we captured the log message about using SARIF instead of findings data + // Removed assertion for log message since logger is now nil +} + +func TestScanner_Scan_SARIFTransformationError(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockData := mocks.NewMockData(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Mock an invalid JSON, which will fail JSON parsing entirely + invalidSarifJson := `{ + "incomplete json"` + + // Set up the mock data payload + gomock.InOrder( + mockData.EXPECT().GetContentType().Return("application/sarif+json").AnyTimes(), + mockData.EXPECT().GetPayload().Return(invalidSarifJson).AnyTimes(), + ) + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Expect engine.Invoke to be called with the correct workflow ID and return the mock data + mockEngine.EXPECT().InvokeWithConfig( + localworkflows.WORKFLOWID_CODE, + gomock.Any(), + ).Return([]workflow.Data{mockData}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method + scanner.Scan(context.Background(), "/test/path", mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify the scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + + // Since the SARIF data is malformed JSON, there should be an error + assert.NotNil(t, scanData.Err, "Expected error with malformed SARIF") + assert.Contains(t, scanData.Err.Error(), "failed to parse SARIF", "Expected error about SARIF parsing") +} + +func TestScanner_Scan_ConfigurationDetails(t *testing.T) { + // Setup + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockEngine := mocks.NewMockEngine(ctrl) + mockConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + // Create a channel to capture the processed scan data + scanDataCh := make(chan types.ScanData, 1) + + mockScanResultProcessor := func(ctx context.Context, scanData types.ScanData) { + // Send the scan data to the channel for verification + scanDataCh <- scanData + } + + // Define the test path + testPath := types.FilePath("/specific/test/path") + + // Mock the GetConfiguration method + mockEngine.EXPECT().GetConfiguration().Return(mockConfig).AnyTimes() + + // Mock GetLogger to return nil + mockEngine.EXPECT().GetLogger().Return(nil).AnyTimes() + + // Use simple invocation without trying to inspect the config + mockEngine.EXPECT().InvokeWithConfig( + gomock.Eq(localworkflows.WORKFLOWID_CODE), + gomock.Any(), + ).Return([]workflow.Data{}, nil) + + // Create the scanner instance + scanner := &Scanner{ + engine: mockEngine, + product: product.ProductCode, + } + + // Execute the Scan method with a specific path + scanner.Scan(context.Background(), testPath, mockScanResultProcessor, "/test") + + // Get the scan data from the channel and verify it + scanData := <-scanDataCh + + // Verify basic scan data properties + assert.Equal(t, product.ProductCode, scanData.Product, "Expected product to be Snyk Code") + assert.Equal(t, testPath, scanData.Path, "Expected path to match input path") + + assert.Nil(t, scanData.Err, "Expected no error when no results are returned") + assert.Empty(t, scanData.Issues, "Expected no issues with empty results") +} + +func TestIsNilInterface(t *testing.T) { + // Test with nil value + assert.True(t, isNilInterface(nil), "Expected nil interface to be detected as nil") + + // Test with a non-nil value + var nonNil string = "test" + assert.False(t, isNilInterface(nonNil), "Expected non-nil value to not be detected as nil") + + // Test with a nil pointer + var nilPtr *string + assert.True(t, isNilInterface(nilPtr), "Expected nil pointer to be detected as nil") + + // Test with a struct with nil fields + type testStruct struct { + Field *string + } + var structWithNilField testStruct + assert.False(t, isNilInterface(structWithNilField), "Expected struct with nil field to not be detected as nil") +} diff --git a/pkg/types/command.go b/pkg/types/command.go new file mode 100644 index 000000000..5aaabe5aa --- /dev/null +++ b/pkg/types/command.go @@ -0,0 +1,59 @@ +/* + * © 2022-2024 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import ( + "context" +) + +type Command interface { + Command() CommandData + Execute(ctx context.Context) (any, error) +} + +type CommandData struct { + /** + * Title of the command, like `save`. + */ + Title string + /** + * The identifier of the actual command handler. + */ + CommandId string + /** + * Arguments that the command handler should be + * invoked with. + */ + Arguments []any + GroupingKey Key + GroupingType GroupingType + GroupingValue any +} + +func (c CommandData) GetGroupingKey() Key { + return c.GroupingKey +} + +func (c CommandData) GetGroupingValue() any { + return c.GroupingValue +} + +func (c CommandData) GetGroupingType() GroupingType { + return c.GroupingType +} + +type CommandName string diff --git a/pkg/types/edit.go b/pkg/types/edit.go new file mode 100644 index 000000000..458dd01a8 --- /dev/null +++ b/pkg/types/edit.go @@ -0,0 +1,39 @@ +/* + * © 2022-2025 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +type TextEdit struct { + + /** + * The range of the text document to be manipulated. To insert + * text into a document create a range where start === end. + */ + Range Range + + /** + * The string to be inserted. For delete operations use an + * empty string. + */ + NewText string +} + +type WorkspaceEdit struct { + /** + * Holds changes to existing resources, keyed on the affected file path. + */ + Changes map[string][]TextEdit +} diff --git a/pkg/types/folder_config.go b/pkg/types/folder_config.go new file mode 100644 index 000000000..1ef85093c --- /dev/null +++ b/pkg/types/folder_config.go @@ -0,0 +1,27 @@ +package types + +import "github.com/snyk/go-application-framework/pkg/product" + +// ScanCommandConfig allows to define a command that is run before (PreScanCommand) +// or after (PostScanCommand) a scan. It will only be run for the +// referenceFolder / referenceScan (in case of delta) if the corresponding +// parameter PreScanOnlyReferenceFolder / PostScanOnlyReferenceFolder is set. +// Else it will run for all scans. +type ScanCommandConfig struct { + PreScanCommand string `json:"command,omitempty"` + PreScanOnlyReferenceFolder bool `json:"preScanOnlyReferenceFolder,omitempty"` + PostScanCommand string `json:"postScanCommand,omitempty"` + PostScanOnlyReferenceFolder bool `json:"postScanOnlyReferenceFolder,omitempty"` +} + +// FolderConfig is exchanged between IDE and LS +// IDE sends this as part of the settings/initialization +// LS sends this via the $/snyk.folderConfig notification +type FolderConfig struct { + FolderPath FilePath `json:"folderPath"` + BaseBranch string `json:"baseBranch"` + LocalBranches []string `json:"localBranches,omitempty"` + AdditionalParameters []string `json:"additionalParameters,omitempty"` + ReferenceFolderPath FilePath `json:"referenceFolderPath,omitempty"` + ScanCommandConfig map[product.Product]ScanCommandConfig `json:"scanCommandConfig,omitempty"` +} diff --git a/pkg/types/grouping.go b/pkg/types/grouping.go new file mode 100644 index 000000000..c7273444c --- /dev/null +++ b/pkg/types/grouping.go @@ -0,0 +1,75 @@ +/* + * © 2024 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import ( + "github.com/rs/zerolog" + "golang.org/x/mod/semver" +) + +type Key string + +type Groupable interface { + GetGroupingKey() Key + GetGroupingValue() any + GetGroupingType() GroupingType +} + +type Filterable interface { + GetFilteringKey() Key +} + +type GroupingFunction func(groupables []Groupable) any +type GroupingType string + +const Quickfix GroupingType = "quickfix-grouping" + +func MaxSemver(logger zerolog.Logger) GroupingFunction { + return func(groupables []Groupable) any { + if len(groupables) == 0 { + return nil + } + + // find max semver version + var chosenGroupable = groupables[0] + for _, groupable := range groupables { + if currentVersion, ok := groupable.GetGroupingValue().(string); ok { + currentVersion = "v" + currentVersion + if !semver.IsValid(currentVersion) { + continue + } + + if chosenGroupable == nil { + chosenGroupable = groupable + } + + group, ok := chosenGroupable.GetGroupingValue().(string) + if !ok { + logger.Debug().Msgf("could not cast first groupable value %v to string, skipping", chosenGroupable.GetGroupingValue()) + continue + } + + chosenVersion := "v" + group + if semver.Compare(chosenVersion, currentVersion) < 0 { + chosenGroupable = groupable + } + } + } + + return chosenGroupable + } +} diff --git a/pkg/types/issues.go b/pkg/types/issues.go new file mode 100644 index 000000000..f44a3b953 --- /dev/null +++ b/pkg/types/issues.go @@ -0,0 +1,234 @@ +/* + * © 2025 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import ( + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/google/uuid" + "github.com/snyk/go-application-framework/pkg/product" +) + +type Severity int8 + +const ( + Critical Severity = iota + High + Medium + Low +) + +func (s Severity) String() string { + switch s { + case Critical: + return "critical" + case High: + return "high" + case Medium: + return "medium" + case Low: + return "low" + default: + return "unknown" + } +} + +// Type of issue, these will typically match 1o1 to Snyk product lines but are not necessarily coupled to those. +type IssueType int8 + +const ( + PackageHealth IssueType = iota + CodeQualityIssue + CodeSecurityVulnerability + LicenseIssue + DependencyVulnerability + InfrastructureIssue + ContainerVulnerability +) + +type CodeAction interface { + Groupable + GetTitle() string + GetIsPreferred() *bool + GetEdit() *WorkspaceEdit + GetDeferredEdit() *func() *WorkspaceEdit + GetCommand() *CommandData + GetDeferredCommand() *func() *CommandData + GetUuid() *uuid.UUID + SetTitle(title string) + SetEdit(edit *WorkspaceEdit) +} + +type Reference struct { + Title string + Url *url.URL +} + +type Issue interface { + fmt.Stringer + GetID() string + GetRange() Range + GetMessage() string + GetFormattedMessage() string + GetAffectedFilePath() FilePath + GetIsNew() bool + GetIsIgnored() bool + GetSeverity() Severity + GetIgnoreDetails() *IgnoreDetails + GetProduct() product.Product + GetFingerprint() string + GetGlobalIdentity() string + GetAdditionalData() IssueAdditionalData + GetEcosystem() string + GetCWEs() []string + GetCVEs() []string + GetIssueType() IssueType + GetLessonUrl() string + GetIssueDescriptionURL() *url.URL + GetCodeActions() []CodeAction + GetCodelensCommands() []CommandData + GetFilterableIssueType() product.FilterableIssueType + GetRuleID() string + GetReferences() []Reference + SetCodelensCommands(lenses []CommandData) + SetLessonUrl(url string) + SetAdditionalData(data IssueAdditionalData) + SetGlobalIdentity(globalIdentity string) + SetIsNew(isNew bool) + SetCodeActions(actions []CodeAction) + SetRange(r Range) +} + +type IgnoreDetails struct { + Category string `json:"category"` + Reason string `json:"reason"` + Expiration string `json:"expiration"` + IgnoredOn time.Time `json:"ignoredOn"` + IgnoredBy string `json:"ignoredBy"` +} + +type IssueAdditionalData interface { + json.Marshaler + GetKey() string + GetTitle() string + IsFixable() bool + GetFilterableIssueType() product.FilterableIssueType +} + +type SeverityIssueCounts map[Severity]IssueCount +type IssueCount struct { + Total int + Open int + Ignored int +} + +func (s ScanData) GetSeverityIssueCounts() SeverityIssueCounts { + sic := make(SeverityIssueCounts) + + for _, issue := range s.Issues { + UpdateSeverityCount(sic, issue) + } + + return sic +} + +func UpdateSeverityCount(sic SeverityIssueCounts, issue Issue) { + ic, exists := sic[issue.GetSeverity()] + if !exists { + ic = IssueCount{} + } + if issue.GetIsIgnored() { + ic.Ignored++ + } else { + ic.Open++ + } + ic.Total++ + + sic[issue.GetSeverity()] = ic +} + +type FilePath string + +type IssuesByFile map[FilePath][]Issue + +func (f IssuesByFile) SeverityCountsAsString(critical, high, medium, low int) string { + var severityCounts string + if critical > 0 { + severityCounts += fmt.Sprintf("%d critical", critical) + } + + if high > 0 { + if !isFirstSeverity(severityCounts) { + severityCounts += "," + } + severityCounts += fmt.Sprintf("%d high", high) + } + + if medium > 0 { + if !isFirstSeverity(severityCounts) { + severityCounts += "," + } + severityCounts += fmt.Sprintf("%d medium", medium) + } + + if low > 0 { + if !isFirstSeverity(severityCounts) { + severityCounts += "," + } + severityCounts += fmt.Sprintf("%d low", low) + } + + return severityCounts +} + +func (f IssuesByFile) SeverityCounts() (total, critical, high, medium, low int) { + for _, issues := range f { + for _, issue := range issues { + total++ + switch issue.GetSeverity() { + case Critical: + critical++ + case High: + high++ + case Medium: + medium++ + case Low: + low++ + } + } + } + return total, critical, high, medium, low +} + +func (f IssuesByFile) FixableCount() int { + var fixableCount int + for _, issues := range f { + for _, issue := range issues { + if issue.GetAdditionalData().IsFixable() { + fixableCount++ + } + } + } + return fixableCount +} + +func isFirstSeverity(severityCounts string) bool { + return len(severityCounts) > 0 +} diff --git a/pkg/types/issues_provider.go b/pkg/types/issues_provider.go new file mode 100644 index 000000000..5e0d5a293 --- /dev/null +++ b/pkg/types/issues_provider.go @@ -0,0 +1,45 @@ +/* + * © 2024 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import ( + "github.com/snyk/go-application-framework/pkg/product" +) + +type ProductIssuesByFile map[product.Product]IssuesByFile + +// IssueProvider is an interface that allows to retrieve issues for a given path and range. +// This is used instead of any concrete dependency to allow for easier testing and more flexibility in implementation. +type IssueProvider interface { + IssuesForFile(path FilePath) []Issue + IssuesForRange(path FilePath, r Range) []Issue + Issue(key string) Issue + Issues() IssuesByFile +} + +type CacheProvider interface { + IssueProvider + IsProviderFor(issueType product.FilterableIssueType) bool + Clear() + ClearIssues(path FilePath) + RegisterCacheRemovalHandler(handler func(path FilePath)) +} + +type FilteringIssueProvider interface { + IssueProvider + FilterIssues(issues IssuesByFile, supportedIssueTypes map[product.FilterableIssueType]bool) IssuesByFile +} diff --git a/pkg/types/range.go b/pkg/types/range.go new file mode 100644 index 000000000..62424c5c8 --- /dev/null +++ b/pkg/types/range.go @@ -0,0 +1,87 @@ +/* + * © 2022-2025 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import "fmt" + +type Position struct { + /** + * Line position in a document (zero-based). + */ + Line int + /** + * Character offset on a line in a document (zero-based). + */ + Character int +} + +func (p Position) String() string { + return fmt.Sprintf("%d:%d", p.Line, p.Character) +} + +type Range struct { + /** + * The range's start position. + */ + Start Position + + /** + * The range's end position. + */ + End Position +} + +func (r Range) String() string { + return fmt.Sprintf("%s-%s", r.Start, r.End) +} + +// Contains returns true if the otherRange is contained within the range +func (r Range) Contains(otherRange Range) bool { + if otherRange.Start.Line < r.Start.Line || otherRange.End.Line < r.Start.Line { + return false + } + if otherRange.Start.Line > r.End.Line || otherRange.End.Line > r.End.Line { + return false + } + if otherRange.Start.Line == r.Start.Line && otherRange.Start.Character < r.Start.Character { + return false + } + if otherRange.End.Line == r.End.Line && otherRange.End.Character > r.End.Character { + return false + } + return true +} + +// Overlaps returns true if the otherRange overlaps with the range +func (r Range) Overlaps(otherRange Range) bool { + if r.Contains(otherRange) { + return true + } + if otherRange.End.Line < r.Start.Line { + return false + } + if otherRange.Start.Line > r.End.Line { + return false + } + if otherRange.End.Line <= r.Start.Line && otherRange.End.Character < r.End.Character { + return false + } + if otherRange.End.Line <= r.End.Line && otherRange.Start.Character > r.End.Character { + return false + } + return true +} diff --git a/pkg/types/range_test.go b/pkg/types/range_test.go new file mode 100644 index 000000000..4328b1b7a --- /dev/null +++ b/pkg/types/range_test.go @@ -0,0 +1,81 @@ +/* + * © 2022-2025 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import "testing" + +//nolint:dupl // test cases differ by a boolean +func Test_Range_Contains(t *testing.T) { + r := Range{ + Start: Position{5, 10}, + End: Position{6, 20}, + } + tests := []struct { + name string + otherRange Range + want bool + }{ + {"Other Range on different line", Range{Start: Position{Line: 4, Character: 1}, End: Position{Line: 4, Character: 20}}, false}, + {"Other Range on same line but left of range", Range{Start: Position{Line: 5, Character: 1}, End: Position{Line: 5, Character: 9}}, false}, + {"Other Range on same line but right of range", Range{Start: Position{Line: 6, Character: 21}, End: Position{Line: 6, Character: 22}}, false}, + {"Other Range starts in range and ends outside", Range{Start: Position{Line: 5, Character: 11}, End: Position{Line: 7, Character: 20}}, false}, + {"Other Range starts before range and ends in range", Range{Start: Position{Line: 5, Character: 1}, End: Position{Line: 5, Character: 20}}, false}, + {"Other Range starts before range and ends within range", Range{Start: Position{Line: 3, Character: 1}, End: Position{Line: 5, Character: 20}}, false}, + {"Other Range starts in range and ends within range", Range{Start: Position{Line: 5, Character: 11}, End: Position{Line: 5, Character: 19}}, true}, + {"Other Range exactly the same", r, true}, + {"Other Range starts exactly with range and ends within range", Range{Start: Position{Line: 5, Character: 10}, End: Position{Line: 5, Character: 19}}, true}, + {"Other Range starts within range and ends exactly with range", Range{Start: Position{Line: 5, Character: 11}, End: Position{Line: 5, Character: 20}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := r.Contains(tt.otherRange); got != tt.want { + t.Errorf("Contains() = %v, want %v", got, tt.want) + } + }) + } +} + +//nolint:dupl // test cases differ by a boolean +func Test_Range_Overlaps(t *testing.T) { + r := Range{ + Start: Position{5, 10}, + End: Position{6, 20}, + } + tests := []struct { + name string + otherRange Range + want bool + }{ + {"Other Range on different line", Range{Start: Position{Line: 4, Character: 1}, End: Position{Line: 4, Character: 20}}, false}, + {"Other Range on same line but left of range", Range{Start: Position{Line: 5, Character: 1}, End: Position{Line: 5, Character: 9}}, false}, + {"Other Range on end line but right of range", Range{Start: Position{Line: 6, Character: 21}, End: Position{Line: 6, Character: 22}}, false}, + {"Other Range starts in range and ends outside", Range{Start: Position{Line: 5, Character: 11}, End: Position{Line: 7, Character: 20}}, true}, + {"Other Range starts before range and ends in range", Range{Start: Position{Line: 5, Character: 1}, End: Position{Line: 5, Character: 20}}, true}, + {"Other Range starts before range on different line and ends in range", Range{Start: Position{Line: 3, Character: 1}, End: Position{Line: 5, Character: 20}}, true}, + {"Other Range starts in range and ends within range", Range{Start: Position{Line: 5, Character: 11}, End: Position{Line: 5, Character: 19}}, true}, + {"Other Range exactly the same", r, true}, + {"Other Range starts exactly with range and ends within range", Range{Start: Position{Line: 5, Character: 10}, End: Position{Line: 5, Character: 19}}, true}, + {"Other Range starts within range and ends exactly with range", Range{Start: Position{Line: 5, Character: 11}, End: Position{Line: 5, Character: 20}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := r.Overlaps(tt.otherRange); got != tt.want { + t.Errorf("Overlaps() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/types/scan.go b/pkg/types/scan.go new file mode 100644 index 000000000..cf43125df --- /dev/null +++ b/pkg/types/scan.go @@ -0,0 +1,53 @@ +/* + * © 2025 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import ( + "context" + "time" + + "github.com/snyk/go-application-framework/pkg/product" +) + +type ScanResultProcessor = func(ctx context.Context, scanData ScanData) + +func NoopResultProcessor(_ context.Context, _ ScanData) {} + +type ScanData struct { + Product product.Product + Issues []Issue + Err error + DurationMs time.Duration + TimestampFinished time.Time + Path FilePath + IsDeltaScan bool + SendAnalytics bool + UpdateGlobalCache bool +} + +type Scanner interface { + // Scan scans a workspace folder or file for issues, given its path. 'folderPath' provides a path to a workspace folder, if a file needs to be scanned. + Scan(ctx context.Context, path FilePath, processResults ScanResultProcessor, folderPath FilePath) +} + +type ProductScanner interface { + // Scan scans a workspace folder or file for issues, given its path. 'folderPath' provides a path to a workspace folder, if a file needs to be scanned. + Scan(ctx context.Context, path FilePath, folderPath FilePath, folderConfig *FolderConfig) (issues []Issue, err error) + + IsEnabled() bool + Product() product.Product +} diff --git a/pkg/types/server.go b/pkg/types/server.go new file mode 100644 index 000000000..875c4b4b6 --- /dev/null +++ b/pkg/types/server.go @@ -0,0 +1,44 @@ +/* + * © 2023-2024 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import ( + "context" + + "github.com/creachadair/jrpc2" +) + +/* + * © 2023 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type Server interface { + Notify(ctx context.Context, method string, params any) error + Callback(ctx context.Context, method string, params any) (*jrpc2.Response, error) +} diff --git a/pkg/types/severity_filter.go b/pkg/types/severity_filter.go new file mode 100644 index 000000000..3b13c3c9a --- /dev/null +++ b/pkg/types/severity_filter.go @@ -0,0 +1,42 @@ +/* + * © 2022-2024 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +func NewSeverityFilter(critical bool, high bool, medium bool, low bool) SeverityFilter { + return SeverityFilter{ + Critical: critical, + High: high, + Medium: medium, + Low: low, + } +} + +func DefaultSeverityFilter() SeverityFilter { + return SeverityFilter{ + Critical: true, + High: true, + Medium: true, + Low: true, + } +} + +type SeverityFilter struct { + Critical bool `json:"critical,omitempty"` + High bool `json:"high,omitempty"` + Medium bool `json:"medium,omitempty"` + Low bool `json:"low,omitempty"` +}