Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion domain/ide/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,13 @@ func ToCodeAction(issue types.Issue, action types.CodeAction) types.LSPCodeActio
i := types.CodeActionData(*action.GetUuid())
id = &i
}
kind := action.GetKind()
if kind == types.Empty {
kind = types.QuickFix
}
return types.LSPCodeAction{
Title: action.GetTitle(),
Kind: types.QuickFix,
Kind: kind,
Diagnostics: ToDiagnostics([]types.Issue{issue}),
IsPreferred: action.GetIsPreferred(),
Edit: ToWorkspaceEdit(action.GetEdit()),
Expand Down Expand Up @@ -236,6 +240,7 @@ func getOssIssue(issue types.Issue) types.ScanIssue {

scanIssue := types.ScanIssue{
Id: additionalData.Key,
FindingId: issue.GetFindingId(),
Title: additionalData.Title,
Severity: issue.GetSeverity().String(),
FilePath: issue.GetAffectedFilePath(),
Expand Down Expand Up @@ -287,6 +292,7 @@ func getIacIssue(issue types.Issue) types.ScanIssue {

scanIssue := types.ScanIssue{
Id: additionalData.Key,
FindingId: issue.GetFindingId(),
Title: additionalData.Title,
Severity: issue.GetSeverity().String(),
FilePath: issue.GetAffectedFilePath(),
Expand Down Expand Up @@ -348,6 +354,7 @@ func getCodeIssue(issue types.Issue) types.ScanIssue {

scanIssue := types.ScanIssue{
Id: additionalData.Key,
FindingId: issue.GetFindingId(),
Title: issue.GetMessage(),
Severity: issue.GetSeverity().String(),
FilePath: issue.GetAffectedFilePath(),
Expand Down Expand Up @@ -396,6 +403,7 @@ func getSecretIssue(issue types.Issue) types.ScanIssue {

scanIssue := types.ScanIssue{
Id: additionalData.Key,
FindingId: issue.GetFindingId(),
Title: additionalData.Title,
Severity: issue.GetSeverity().String(),
FilePath: issue.GetAffectedFilePath(),
Expand Down
172 changes: 172 additions & 0 deletions domain/ide/converter/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,175 @@ func TestGetCvssCalculatorUrl(t *testing.T) {
assert.Equal(t, expected, url)
})
}

// TestToDiagnostics_FindingId verifies that ScanIssue.FindingId is populated from issue.GetFindingId()
// for each product. A stable FindingId allows clients to correlate the same underlying finding across
// separate scan invocations without relying on the per-result-set Id field.
func TestToDiagnostics_FindingId_Code(t *testing.T) {
testutil.UnitTest(t)

const expectedFindingId = "snyk-asset-finding-v1-abc123"
testIssue := &snyk.Issue{
ID: "code-rule-id",
Severity: types.High,
Product: product.ProductCode,
FindingId: expectedFindingId,
AdditionalData: snyk.CodeIssueData{
Key: "code-key-1",
},
}

diagnostics := ToDiagnostics([]types.Issue{testIssue})

require.Len(t, diagnostics, 1)
assert.Equal(t, expectedFindingId, diagnostics[0].Data.FindingId,
"ScanIssue.FindingId must equal issue.GetFindingId() for Code issues")
}

func TestToDiagnostics_FindingId_OSS(t *testing.T) {
testutil.UnitTest(t)

const expectedFindingId = "oss-introducing-finding-id-xyz"
testIssue := &snyk.Issue{
ID: "oss-vuln-id",
Severity: types.Medium,
Product: product.ProductOpenSource,
FindingId: expectedFindingId,
AdditionalData: snyk.OssIssueData{
Key: "oss-key-1",
},
}

diagnostics := ToDiagnostics([]types.Issue{testIssue})

require.Len(t, diagnostics, 1)
assert.Equal(t, expectedFindingId, diagnostics[0].Data.FindingId,
"ScanIssue.FindingId must equal issue.GetFindingId() for OSS issues")
}

func TestToDiagnostics_FindingId_Secrets(t *testing.T) {
testutil.UnitTest(t)

const expectedFindingId = "secret-attrs-key-99"
testIssue := &snyk.Issue{
ID: "secret-rule-id",
Severity: types.High,
Product: product.ProductSecrets,
FindingId: expectedFindingId,
AdditionalData: snyk.SecretsIssueData{
Key: "secret-key-99",
Title: "Hardcoded Secret",
},
}

diagnostics := ToDiagnostics([]types.Issue{testIssue})

require.Len(t, diagnostics, 1)
assert.Equal(t, expectedFindingId, diagnostics[0].Data.FindingId,
"ScanIssue.FindingId must equal issue.GetFindingId() for Secrets issues")
}

// TestToDiagnostics_FindingId_IaC documents that IaC findings currently emit an empty FindingId.
// The IaC scanner does not yet set FindingId on snyk.Issue, so ScanIssue.FindingId is always "".
// When the IaC scanner is updated to set FindingId, this test should be updated to assert the
// expected non-empty value.
func TestToDiagnostics_FindingId_IaC(t *testing.T) {
testutil.UnitTest(t)

testIssue := &snyk.Issue{
ID: "iac-rule-id",
Severity: types.High,
Product: product.ProductInfrastructureAsCode,
// FindingId is intentionally not set: the IaC scanner does not yet populate it.
AdditionalData: snyk.IaCIssueData{
Key: "iac-key-1",
Title: "IaC misconfiguration",
},
}

diagnostics := ToDiagnostics([]types.Issue{testIssue})

require.Len(t, diagnostics, 1)
assert.Empty(t, diagnostics[0].Data.FindingId,
"IaC issues emit empty FindingId until the IaC scanner populates it")
}

// TestToDiagnostics_FindingId_DeterministicConversion verifies that ToDiagnostics is a pure
// function: two calls on the same issue struct produce identical FindingId values.
func TestToDiagnostics_FindingId_DeterministicConversion(t *testing.T) {
testutil.UnitTest(t)

const stableFindingId = "stable-code-fingerprint-v1"
testIssue := &snyk.Issue{
ID: "code-rule-id",
Severity: types.High,
Product: product.ProductCode,
FindingId: stableFindingId,
AdditionalData: snyk.CodeIssueData{
Key: "code-key-stable",
},
}

diagnostics1 := ToDiagnostics([]types.Issue{testIssue})
diagnostics2 := ToDiagnostics([]types.Issue{testIssue})

require.Len(t, diagnostics1, 1)
require.Len(t, diagnostics2, 1)
assert.Equal(t, diagnostics1[0].Data.FindingId, diagnostics2[0].Data.FindingId,
"FindingId must be identical across two separate conversions of the same finding")
assert.Equal(t, stableFindingId, diagnostics1[0].Data.FindingId,
"FindingId must match the value from issue.GetFindingId()")
}

func TestToCodeAction_KindDerived_RemediationAgent(t *testing.T) {
testutil.UnitTest(t)

issue := &snyk.Issue{}
action := &snyk.CodeAction{
Title: "Fix with Snyk Remediation Agent",
OriginalTitle: "Fix with Snyk Remediation Agent",
Kind: types.RemediationAgentQuickFix,
Command: nil,
Edit: &types.WorkspaceEdit{Changes: map[string][]types.TextEdit{}},
}
issue.CodeActions = []types.CodeAction{action}

result := ToCodeAction(issue, action)

assert.Equal(t, types.RemediationAgentQuickFix, result.Kind)
}

func TestToCodeAction_KindDerived_Empty_FallsBackToQuickFix(t *testing.T) {
testutil.UnitTest(t)

issue := &snyk.Issue{}
action := &snyk.CodeAction{
Title: "Some action",
OriginalTitle: "Some action",
Kind: types.Empty,
Command: nil,
Edit: &types.WorkspaceEdit{Changes: map[string][]types.TextEdit{}},
}
issue.CodeActions = []types.CodeAction{action}

result := ToCodeAction(issue, action)

assert.Equal(t, types.QuickFix, result.Kind)
}

func TestToCodeAction_KindDerived_ExistingQuickfix_NoRegression(t *testing.T) {
testutil.UnitTest(t)

issue := &snyk.Issue{}
action := &snyk.CodeAction{
Title: "Fix this",
OriginalTitle: "Fix this",
// Kind is zero-value (empty string) — existing path
Edit: &types.WorkspaceEdit{Changes: map[string][]types.TextEdit{}},
}
issue.CodeActions = []types.CodeAction{action}

result := ToCodeAction(issue, action)

assert.Equal(t, types.QuickFix, result.Kind, "existing actions without explicit Kind must fall back to QuickFix")
}
9 changes: 9 additions & 0 deletions domain/snyk/codeaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ type CodeAction struct {

// The type of grouping to determine the grouping function to be used
GroupingType types.GroupingType

// Kind is the LSP code action kind. When empty the converter falls back to QuickFix.
Kind types.CodeActionKind
}

func (c *CodeAction) SetEdit(edit *types.WorkspaceEdit) {
Expand Down Expand Up @@ -127,6 +130,12 @@ func (c *CodeAction) GetGroupingType() types.GroupingType {
return c.GroupingType
}

// GetKind returns the LSP CodeActionKind. Kind is set at construction time via the Kind field;
// there is no SetKind because kind is an immutable property of the action type.
func (c *CodeAction) GetKind() types.CodeActionKind {
return c.Kind
}

func NewCodeAction(title string, edit *types.WorkspaceEdit, command *types.CommandData) (*CodeAction, error) {
if edit == nil && command == nil {
return nil, errors.New("a non-deferred action must have either an edit or a command")
Expand Down
1 change: 1 addition & 0 deletions internal/types/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type CodeAction interface {
GetCommand() *CommandData
GetDeferredCommand() *func() *CommandData
GetUuid() *uuid.UUID
GetKind() CodeActionKind
SetTitle(title string)
SetEdit(edit *WorkspaceEdit)
}
Expand Down
10 changes: 9 additions & 1 deletion internal/types/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,10 @@ const SourceOrganizeImports CodeActionKind = "source.organizeImports"
*/
const SourceFixAll CodeActionKind = "source.fixAll"

// RemediationAgentQuickFix is the stable, machine-readable kind clients match on to identify
// the Snyk Remediation Agent fix action. Clients must match on this kind, never on the title.
const RemediationAgentQuickFix CodeActionKind = "quickfix.snyk.remediationAgent"

type CodeActionParams struct {
/**
* The document in which the command was invoked.
Expand Down Expand Up @@ -1122,7 +1126,11 @@ type SnykScanParams struct {

type ScanIssue struct { // TODO - convert this to a generic type
// Unique key identifying an issue in the whole result set. Not the same as the Snyk issue ID.
Id string `json:"id"`
Id string `json:"id"`
// FindingId is stable across scans for the same underlying finding; sourced from Issue.GetFindingId().
// Unlike Id (which is per-result-set), this value is consistent between separate scan invocations
// and allows clients to correlate or deduplicate findings over time.
FindingId string `json:"findingId"`
Title string `json:"title"`
Severity string `json:"severity"`
FilePath FilePath `json:"filePath"`
Expand Down
Loading