Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
54c86b5
feat(learn): support JIT lessons for Snyk Secrets findings [EDU-4754]
SnykOleg May 10, 2026
89f213c
feat(secrets): populate Issue.LessonUrl during scan via learn.Service…
SnykOleg May 10, 2026
448e4c2
feat(secrets): render Snyk Learn link in details panel [EDU-4754]
SnykOleg May 10, 2026
495a433
Merge branch 'main' into edu-4754-secrets-learn-jit
SnykOleg May 22, 2026
9e8df31
refactor(server): inject learn.Service via withContext middleware [ED…
SnykOleg May 26, 2026
3b238bb
refactor(secrets): consume learn.Service from request context, fuse L…
SnykOleg May 26, 2026
d240ba7
merge(main): resolve server DI conflicts for edu-4754 branch [EDU-4754]
SnykOleg Jun 4, 2026
8be3271
fix(secrets): inject learn service into scan context for autosave par…
SnykOleg Jun 4, 2026
f4a2c0c
test(secrets): assert LessonUrl in FindingsConverter via context lear…
SnykOleg Jun 4, 2026
0ca69bc
docs: document secrets Learn JIT tested scenarios [EDU-4754]
SnykOleg Jun 4, 2026
2870bc6
fix(secrets): align IDE issue card with product review [EDU-4754]
SnykOleg Jun 4, 2026
86ec301
chore: PR #1265 product review preview images [EDU-4754]
SnykOleg Jun 4, 2026
0295bfe
chore: PR #1265 product review preview images [EDU-4754]
SnykOleg Jun 4, 2026
3505dc9
chore: remove PR preview PNGs from branch [EDU-4754]
SnykOleg Jun 7, 2026
be13ef1
chore: remove PR preview PNGs from branch [EDU-4754]
SnykOleg Jun 7, 2026
0e2f362
fix(secrets): copy deps map in enrichContext for thread safety [EDU-4…
SnykOleg Jun 7, 2026
33e6557
docs: move secrets issue panel docs under UI rendering section [EDU-4…
SnykOleg Jun 7, 2026
770918f
chore: PR #1265 no-learn preview for product review [EDU-4754]
SnykOleg Jun 7, 2026
61d573d
chore: remove PR preview PNG from branch [EDU-4754]
SnykOleg Jun 7, 2026
6883fb1
Merge branch 'main' into edu-4754-secrets-learn-jit
SnykOleg Jun 14, 2026
aede219
Merge branch 'main' into edu-4754-secrets-learn-jit
SnykOleg Jun 15, 2026
d3bcd17
Merge branch 'main' into edu-4754-secrets-learn-jit
SnykOleg Jun 16, 2026
814c8ca
merge(main): sync edu-4754-secrets-learn-jit with main [EDU-4754]
SnykOleg Jun 21, 2026
6b098ae
merge: integrate remote branch history [EDU-4754]
SnykOleg Jun 21, 2026
18fcdbc
Merge branch 'main' into edu-4754-secrets-learn-jit
basti-snyk Jun 30, 2026
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ Right now the language server supports the following actions:
LicenceIssue = 2
DependencyVulnerability = 3
InfrastructureIssue = 4
SecretsIssue = 5
```
- `GetLearnSession` returns the given lesson on the Snyk Learn website
- command: `snyk.getLearnLesson`
Expand All @@ -328,6 +329,7 @@ Right now the language server supports the following actions:
LicenceIssue = 2
DependencyVulnerability = 3
InfrastructureIssue = 4
SecretsIssue = 5
```
- result: lesson json
```json5
Expand Down
2 changes: 1 addition & 1 deletion application/di/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func initInfrastructure(tokenService types.TokenService, conf configuration.Conf
openSourceScanner = oss.NewCLIScanner(engine, instrumentor, errorReporter, snykCli, learnService, notifier, configResolver)
scanNotifier, _ = appNotification.NewScanNotifier(notifier, configResolver)
snykCodeScanner = code.New(engine, instrumentor, snykApiClient, codeErrorReporter, learnService, featureFlagService, notifier, codeInstrumentor, codeErrorReporter, code.CreateCodeScanner, configResolver)
snykSecretsScanner = secrets.New(conf, engine, logger, instrumentor, snykApiClient, featureFlagService, notifier, configResolver)
snykSecretsScanner = secrets.New(conf, engine, logger, instrumentor, snykApiClient, featureFlagService, learnService, notifier, configResolver)

cliInitializer = cli.NewInitializer(conf, logger, errorReporter, installer, notifier, snykCli, configResolver)
authInitializer := authentication.NewInitializer(conf, logger, authenticationService, errorReporter, notifier, configResolver)
Expand Down
19 changes: 19 additions & 0 deletions docs/ui-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ func getCodeDetailsHtml(engine workflow.Engine, issue snyk.Issue) string {

<img src="https://github.com/snyk/snyk-ls/assets/1948377/01644706-f884-48cd-b98d-24868030677c" width="640">

#### Snyk Secrets Issue Details Panel

Secrets issue HTML is built in `infrastructure/secrets/secrets_html.go` from
`infrastructure/secrets/template/details.html`.

When `Issue.LessonUrl` is set during scan (CWE-keyed Learn lookup), the panel
shows a “Learn how to remediate Secrets securely” link (conditional
`{{if .LessonUrl}}` block; markup aligned with Code `details.html`). The header
uses a **Location:** label with a line link. Findings with multiple engine
locations in the same file show an `N LOCATIONS IN THIS FILE` banner
(`LocationsCount` from conversion).

Learn code actions (editor lightbulb) are not implemented for secrets; only the
issue-card link. For `snyk.getLearnLesson` / `snyk.openLearnLesson`, see
[README.md](../README.md) (`SecretsIssue` / `issueType=5`).

Tests: `infrastructure/secrets/*_test.go`, `infrastructure/learn/service_test.go`,
`domain/ide/command/*_learn_lesson_test.go`.

4. **Handle Nonce and IDE-Specific Styles**

When dealing with Content Security Policies (CSP) in the HTML generated by the Language Server, it’s important to correctly handle `nonce` attributes for both styles and scripts to ensure they are applied securely.
Expand Down
80 changes: 57 additions & 23 deletions domain/ide/command/get_learn_lesson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,62 @@ import (
//goland:noinspection GoRedundantConversion
func Test_getLearnLesson_Execute(t *testing.T) {
testutil.UnitTest(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()

eco := "javascript"
rule := "javascript%2Fsqlinjection"
cwes := "CWE-89,CWE-ZZ"
cves := "CVE-2020-1234"
data := types.CommandData{
Title: types.GetLearnLesson,
CommandId: types.GetLearnLesson,
Arguments: []any{rule, eco, cwes, cves, float64(types.DependencyVulnerability)},

tests := []struct {
name string
eco string
rule string
cwes string
cves string
issueType types.IssueType
expCWEs []string
expCVEs []string
}{
{
name: "DependencyVulnerability",
eco: "javascript",
rule: "javascript%2Fsqlinjection",
cwes: "CWE-89,CWE-ZZ",
cves: "CVE-2020-1234",
issueType: types.DependencyVulnerability,
expCWEs: []string{"CWE-89", "CWE-ZZ"},
expCVEs: []string{"CVE-2020-1234"},
},
{
// Confirms the JSON-number round-trip lands on int8(5) for SecretsIssue.
name: "SecretsIssue",
eco: "",
rule: "",
cwes: "CWE-798",
cves: "",
issueType: types.SecretsIssue,
expCWEs: []string{"CWE-798"},
expCVEs: []string{""},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

data := types.CommandData{
Title: types.GetLearnLesson,
CommandId: types.GetLearnLesson,
Arguments: []any{tt.rule, tt.eco, tt.cwes, tt.cves, float64(tt.issueType)},
}
mockService := mock_learn.NewMockService(ctrl)
cut := getLearnLesson{learnService: mockService, command: data}
expectedLessonURL := "https://lessonURL"
expectedLesson := &learn.Lesson{Url: expectedLessonURL}
mockService.EXPECT().
GetLesson(tt.eco, tt.rule, tt.expCWEs, tt.expCVEs, tt.issueType).
Return(expectedLesson, nil)

lesson, err := cut.Execute(t.Context())

assert.NoError(t, err)
assert.Equal(t, expectedLesson, lesson)
})
}
mockService := mock_learn.NewMockService(ctrl)
cut := getLearnLesson{learnService: mockService, command: data}
expectedLessonURL := "https://lessonURL"
expectedLesson := &learn.Lesson{Url: expectedLessonURL}
mockService.EXPECT().
GetLesson(eco, rule, []string{"CWE-89", "CWE-ZZ"}, []string{"CVE-2020-1234"}, types.DependencyVulnerability).
Return(expectedLesson, nil)

lesson, err := cut.Execute(t.Context())

assert.NoError(t, err)
assert.Equal(t, expectedLesson, lesson)
}
87 changes: 60 additions & 27 deletions domain/ide/command/open_learn_lesson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,69 @@ import (
//goland:noinspection GoRedundantConversion because a json unmarshal would produce a float64, not an int8
func Test_openLearnLesson_Execute(t *testing.T) {
testutil.UnitTest(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// overwrite openbrowser func
openBrowserCalledChan := make(chan string)
openBrowserHandlerFunc := func(url string) {
go func() { openBrowserCalledChan <- url }()
tests := []struct {
name string
eco string
rule string
cwes string
cves string
issueType types.IssueType
expCWEs []string
expCVEs []string
}{
{
name: "DependencyVulnerability",
eco: "javascript",
rule: "javascript%2Fsqlinjection",
cwes: "CWE-89,CWE-ZZ",
cves: "CVE-2020-1234",
issueType: types.DependencyVulnerability,
expCWEs: []string{"CWE-89", "CWE-ZZ"},
expCVEs: []string{"CVE-2020-1234"},
},
{
// Confirms the JSON-number round-trip lands on int8(5) for SecretsIssue.
name: "SecretsIssue",
eco: "",
rule: "",
cwes: "CWE-798",
cves: "",
issueType: types.SecretsIssue,
expCWEs: []string{"CWE-798"},
expCVEs: []string{""},
},
}

eco := "javascript"
rule := "javascript%2Fsqlinjection"
cwes := "CWE-89,CWE-ZZ"
cves := "CVE-2020-1234"
data := types.CommandData{
Title: types.OpenLearnLesson,
CommandId: types.OpenLearnLesson,
Arguments: []any{rule, eco, cwes, cves, float64(types.DependencyVulnerability)},
}
mockService := mock_learn.NewMockService(ctrl)
cut := openLearnLesson{learnService: mockService, command: data, openBrowserHandleFunc: openBrowserHandlerFunc}
expectedLessonURL := "https://lessonURL"
expectedLesson := &learn.Lesson{Url: expectedLessonURL}
mockService.EXPECT().
GetLesson(eco, rule, []string{"CWE-89", "CWE-ZZ"}, []string{"CVE-2020-1234"}, types.DependencyVulnerability).
Return(expectedLesson, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

openBrowserCalledChan := make(chan string)
openBrowserHandlerFunc := func(url string) {
go func() { openBrowserCalledChan <- url }()
}

_, err := cut.Execute(t.Context())
data := types.CommandData{
Title: types.OpenLearnLesson,
CommandId: types.OpenLearnLesson,
Arguments: []any{tt.rule, tt.eco, tt.cwes, tt.cves, float64(tt.issueType)},
}
mockService := mock_learn.NewMockService(ctrl)
cut := openLearnLesson{learnService: mockService, command: data, openBrowserHandleFunc: openBrowserHandlerFunc}
expectedLessonURL := "https://lessonURL"
expectedLesson := &learn.Lesson{Url: expectedLessonURL}
mockService.EXPECT().
GetLesson(tt.eco, tt.rule, tt.expCWEs, tt.expCVEs, tt.issueType).
Return(expectedLesson, nil)

assert.NoError(t, err)
assert.Eventuallyf(t, func() bool {
return expectedLessonURL == <-openBrowserCalledChan
}, 5*time.Second, time.Millisecond, "open browser was not called")
_, err := cut.Execute(t.Context())

assert.NoError(t, err)
assert.Eventuallyf(t, func() bool {
return expectedLessonURL == <-openBrowserCalledChan
}, 5*time.Second, time.Millisecond, "open browser was not called")
})
}
}
2 changes: 1 addition & 1 deletion domain/snyk/secrets_issue_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type SecretsIssueData struct {
Cols CodePoint `json:"cols"`
// Rows is the row range [startLine, endLine] (0-based)
Rows CodePoint `json:"rows"`
// LocationsCount is the number of locations where the secret is found
// LocationsCount is the number of locations for this finding in the same file as this issue
LocationsCount int `json:"locationsCount"`
// Risk Score
RiskScore int `json:"riskScore"`
Expand Down
9 changes: 9 additions & 0 deletions infrastructure/learn/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ func (s *serviceImpl) lessonsLookupParams(
cwes,
cves,
}
case types.SecretsIssue:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix — a Secrets finding with no CWE gets an arbitrary, unrelated lesson. This case passes an empty rule and relies on "CWE intersection through the all-lessons fall-through." That works only when the finding actually has a CWE. When it doesn't (extractProblems returns cwes=nil whenever there's no cwe problem), the lookup goes: empty rule + empty ecosystem → all-lessons branch → filterForCWEs with an empty CWE list, which filterLessonWithComparatorFunc treats as a no-op and returns the full list → GetLesson picks lessons[0]. So the "Learn how to remediate Secrets securely" link points at an arbitrary, unrelated lesson (and the selection is non-deterministic across runs, since GetAll() iterates a map).

Two reviewers flagged this independently. Suggested fix: in the Secrets path (or in enrichWithLearnLesson) skip the lookup / return no lesson when there is no CWE match, so a CWE-less secret gets no link rather than a wrong one. Add a real-service (non-mock) test asserting LessonUrl is empty for a no-CWE secret with a non-empty Learn cache — the current tests mock GetLesson and never exercise this path.

— AI review

// Secrets findings have no Learn-tagged rule and ship with empty Ecosystem;
// rely on CWE intersection through the all-lessons fall-through in GetLesson.
params = &LessonLookupParams{
"",
ecosystem,
cwes,
cves,
}
default:
}
return params
Expand Down
116 changes: 116 additions & 0 deletions infrastructure/learn/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,122 @@ func Test_GetLesson(t *testing.T) {

checkLesson(t, cut, params)
})

t.Run("Snyk Secrets - lesson returned", func(t *testing.T) {
// Secrets findings produce empty Ecosystem and empty Rule (see infrastructure/secrets/convert.go);
// the cache-fall-through to all-lessons + filterForCWEs is what resolves the lesson.
lesson, err := cut.GetLesson("", "", []string{"CWE-798"}, nil, types.SecretsIssue)

assert.NoError(t, err)
assert.NotEmpty(t, lesson)
assert.True(t, strings.HasSuffix(lesson.Url, "?loc=ide"), "should have ?loc=ide suffix")
assert.Contains(t, lesson.Cwes, "CWE-798")
})
}

// Test_lessonsLookupParams is a white-box unit test for the unexported lessonsLookupParams.
// It runs without network access and exercises every supported IssueType so additions for
// new products do not silently regress the existing OSS/Code mappings.
func Test_lessonsLookupParams(t *testing.T) {
testutil.UnitTest(t)
s := &serviceImpl{}

tests := []struct {
name string
ecosystem string
rule string
cwes []string
cves []string
issueType types.IssueType
want *LessonLookupParams
}{
{
name: "DependencyVulnerability passes through rule and ecosystem",
ecosystem: "npm",
rule: "SNYK-JS-ASYNC-2441827",
cwes: []string{"CWE-601"},
cves: []string{"CVE-2021-43138"},
issueType: types.DependencyVulnerability,
want: &LessonLookupParams{
Rule: "SNYK-JS-ASYNC-2441827",
Ecosystem: "npm",
CWEs: []string{"CWE-601"},
CVEs: []string{"CVE-2021-43138"},
},
},
{
name: "CodeSecurityVulnerability splits language/ruleId on '/'",
ecosystem: "ignored-by-code-path",
rule: "javascript/sqlinjection",
cwes: []string{"CWE-89"},
cves: nil,
issueType: types.CodeSecurityVulnerability,
want: &LessonLookupParams{
Rule: "sqlinjection",
Ecosystem: "javascript",
CWEs: []string{"CWE-89"},
CVEs: []string{},
},
},
{
name: "SecretsIssue with empty ecosystem and rule (realistic input from secrets path)",
ecosystem: "",
rule: "",
cwes: []string{"CWE-798"},
cves: nil,
issueType: types.SecretsIssue,
want: &LessonLookupParams{
Rule: "",
Ecosystem: "",
CWEs: []string{"CWE-798"},
CVEs: []string{},
},
},
{
name: "SecretsIssue keeps CVEs when present",
ecosystem: "",
rule: "",
cwes: []string{"CWE-798"},
cves: []string{"CVE-2024-12345"},
issueType: types.SecretsIssue,
want: &LessonLookupParams{
Rule: "",
Ecosystem: "",
CWEs: []string{"CWE-798"},
CVEs: []string{"CVE-2024-12345"},
},
},
{
name: "SecretsIssue takes only the first CWE when multiple are passed",
ecosystem: "",
rule: "",
cwes: []string{"CWE-798", "CWE-259"},
cves: nil,
issueType: types.SecretsIssue,
want: &LessonLookupParams{
Rule: "",
Ecosystem: "",
CWEs: []string{"CWE-798"},
CVEs: []string{},
},
},
{
name: "Unsupported IssueType returns nil (default branch)",
ecosystem: "npm",
rule: "rule-id",
cwes: []string{"CWE-1"},
cves: nil,
issueType: types.LicenseIssue,
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := s.lessonsLookupParams(tt.ecosystem, tt.rule, tt.cwes, tt.cves, tt.issueType)
assert.Equal(t, tt.want, got)
})
}
}

func checkLesson(t *testing.T, cut Service, params LessonLookupParams) {
Expand Down
Loading
Loading