Skip to content

Commit 38fe982

Browse files
committed
feat: [CN-296] Support Container Base Image Recommendations
Adding a new Container LS to support the upgrading the container base images in a docker file.
1 parent 39bced9 commit 38fe982

File tree

15 files changed

+1414
-7
lines changed

15 files changed

+1414
-7
lines changed

application/config/client_settings.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ import (
2222
)
2323

2424
const (
25-
ActivateSnykOssKey = "ACTIVATE_SNYK_OPEN_SOURCE"
26-
ActivateSnykCodeKey = "ACTIVATE_SNYK_CODE"
27-
ActivateSnykIacKey = "ACTIVATE_SNYK_IAC"
28-
ActivateSnykAdvisorKey = "ACTIVATE_SNYK_ADVISOR"
29-
SendErrorReportsKey = "SEND_ERROR_REPORTS"
30-
Organization = "SNYK_CFG_ORG"
25+
ActivateSnykOssKey = "ACTIVATE_SNYK_OPEN_SOURCE"
26+
ActivateSnykCodeKey = "ACTIVATE_SNYK_CODE"
27+
ActivateSnykIacKey = "ACTIVATE_SNYK_IAC"
28+
ActivateSnykContainerKey = "ACTIVATE_SNYK_CONTAINER"
29+
ActivateSnykAdvisorKey = "ACTIVATE_SNYK_ADVISOR"
30+
SendErrorReportsKey = "SEND_ERROR_REPORTS"
31+
Organization = "SNYK_CFG_ORG"
3132
)
3233

3334
func (c *Config) clientSettingsFromEnv() {
@@ -56,6 +57,7 @@ func (c *Config) productEnablementFromEnv() {
5657
oss := os.Getenv(ActivateSnykOssKey)
5758
code := os.Getenv(ActivateSnykCodeKey)
5859
iac := os.Getenv(ActivateSnykIacKey)
60+
container := os.Getenv(ActivateSnykContainerKey)
5961
advisor := os.Getenv(ActivateSnykAdvisorKey)
6062

6163
if oss != "" {
@@ -82,6 +84,14 @@ func (c *Config) productEnablementFromEnv() {
8284
c.SetSnykIacEnabled(parseBool)
8385
}
8486

87+
if container != "" {
88+
parseBool, err := strconv.ParseBool(container)
89+
if err != nil {
90+
c.Logger().Debug().Err(err).Str("method", "clientSettingsFromEnv").Msgf("couldn't parse container config %s", container)
91+
}
92+
c.SetSnykContainerEnabled(parseBool)
93+
}
94+
8595
if advisor != "" {
8696
parseBool, err := strconv.ParseBool(advisor)
8797
if err != nil {

application/config/config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ type Config struct {
163163
isSnykCodeEnabled bool
164164
isSnykOssEnabled bool
165165
isSnykIacEnabled bool
166+
isSnykContainerEnabled bool
166167
isSnykAdvisorEnabled bool
167168
manageBinariesAutomatically bool
168169
logPath string
@@ -432,6 +433,13 @@ func (c *Config) IsSnykIacEnabled() bool {
432433
return c.isSnykIacEnabled
433434
}
434435

436+
func (c *Config) IsSnykContainerEnabled() bool {
437+
c.m.RLock()
438+
defer c.m.RUnlock()
439+
440+
return c.isSnykContainerEnabled
441+
}
442+
435443
func (c *Config) IsSnykAdvisorEnabled() bool {
436444
c.m.RLock()
437445
defer c.m.RUnlock()
@@ -604,6 +612,13 @@ func (c *Config) SetSnykIacEnabled(enabled bool) {
604612
c.isSnykIacEnabled = enabled
605613
}
606614

615+
func (c *Config) SetSnykContainerEnabled(enabled bool) {
616+
c.m.Lock()
617+
defer c.m.Unlock()
618+
619+
c.isSnykContainerEnabled = enabled
620+
}
621+
607622
func (c *Config) SetSnykAdvisorEnabled(enabled bool) {
608623
c.m.Lock()
609624
defer c.m.Unlock()

application/config/product.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ func (c *Config) IsProductEnabled(p product.Product) bool {
2626
return c.IsSnykOssEnabled()
2727
case product.ProductInfrastructureAsCode:
2828
return c.IsSnykIacEnabled()
29+
case product.ProductContainer:
30+
return c.IsSnykContainerEnabled()
2931
default:
3032
return false
3133
}
@@ -38,6 +40,7 @@ func (c *Config) DisplayableIssueTypes() map[product.FilterableIssueType]bool {
3840
// Handle backwards compatibility.
3941
enabled[product.FilterableIssueTypeCodeSecurity] = c.IsSnykCodeEnabled() || c.IsSnykCodeSecurityEnabled()
4042
enabled[product.FilterableIssueTypeInfrastructureAsCode] = c.IsSnykIacEnabled()
43+
enabled[product.FilterableIssueTypeContainer] = c.IsSnykContainerEnabled()
4144

4245
return enabled
4346
}

application/di/init.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"github.com/snyk/snyk-ls/infrastructure/cli/cli_constants"
4242
"github.com/snyk/snyk-ls/infrastructure/cli/install"
4343
"github.com/snyk/snyk-ls/infrastructure/code"
44+
"github.com/snyk/snyk-ls/infrastructure/container"
4445
"github.com/snyk/snyk-ls/infrastructure/iac"
4546
"github.com/snyk/snyk-ls/infrastructure/learn"
4647
"github.com/snyk/snyk-ls/infrastructure/oss"
@@ -55,6 +56,7 @@ import (
5556
var snykApiClient snyk_api.SnykApiClient
5657
var snykCodeScanner *code.Scanner
5758
var infrastructureAsCodeScanner *iac.Scanner
59+
var containerScanner types.ProductScanner
5860
var openSourceScanner types.ProductScanner
5961
var scanInitializer initialize.Initializer
6062
var authenticationService authentication.AuthenticationService
@@ -88,7 +90,7 @@ func Init() {
8890

8991
func initDomain(c *config.Config) {
9092
hoverService = hover.NewDefaultService(c)
91-
scanner = scanner2.NewDelegatingScanner(c, scanInitializer, instrumentor, scanNotifier, snykApiClient, authenticationService, notifier, scanPersister, scanStateAggregator, snykCodeScanner, infrastructureAsCodeScanner, openSourceScanner)
93+
scanner = scanner2.NewDelegatingScanner(c, scanInitializer, instrumentor, scanNotifier, snykApiClient, authenticationService, notifier, scanPersister, scanStateAggregator, snykCodeScanner, infrastructureAsCodeScanner, containerScanner, openSourceScanner)
9294
}
9395

9496
func initInfrastructure(c *config.Config) {
@@ -136,6 +138,7 @@ func initInfrastructure(c *config.Config) {
136138
)
137139

138140
infrastructureAsCodeScanner = iac.New(c, instrumentor, errorReporter, snykCli)
141+
containerScanner = container.New(c, instrumentor, errorReporter, networkAccess)
139142
openSourceScanner = oss.NewCLIScanner(c, instrumentor, errorReporter, snykCli, learnService, notifier)
140143
scanNotifier, _ = appNotification.NewScanNotifier(c, notifier)
141144
snykCodeScanner = code.New(c, instrumentor, snykApiClient, codeErrorReporter, learnService, notifier, codeClientScanner)

ast/dockerfile/parser.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* © 2025 Snyk Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package dockerfile
18+
19+
import (
20+
"regexp"
21+
"strings"
22+
23+
"github.com/rs/zerolog"
24+
25+
"github.com/snyk/snyk-ls/ast"
26+
"github.com/snyk/snyk-ls/internal/types"
27+
)
28+
29+
type Parser struct {
30+
logger zerolog.Logger
31+
}
32+
33+
var (
34+
// fromRegex matches FROM instructions in Dockerfile
35+
fromRegex = regexp.MustCompile(`(?i)^\s*FROM\s+([^\s]+)`)
36+
)
37+
38+
func New(logger *zerolog.Logger) Parser {
39+
return Parser{
40+
logger: *logger,
41+
}
42+
}
43+
44+
func (p *Parser) Parse(content []byte, uri string) *ast.Tree {
45+
tree := p.initTree(types.FilePath(uri), string(content))
46+
47+
lines := strings.Split(strings.ReplaceAll(string(content), "\r", ""), "\n")
48+
for lineNum, line := range lines {
49+
matches := fromRegex.FindStringSubmatch(line)
50+
if matches == nil {
51+
continue
52+
}
53+
54+
baseImage := matches[1]
55+
// Skip scratch base image
56+
if baseImage == "scratch" {
57+
continue
58+
}
59+
60+
node := p.addFromNode(tree.Root, lineNum, line, baseImage)
61+
p.logger.Debug().Interface("nodeName", node.Name).Str("path", tree.Document).Msg("Added FROM node")
62+
}
63+
64+
return tree
65+
}
66+
67+
func (p *Parser) initTree(path types.FilePath, content string) *ast.Tree {
68+
root := ast.Node{
69+
Line: 0,
70+
StartChar: 0,
71+
EndChar: -1,
72+
DocOffset: 0,
73+
Parent: nil,
74+
Children: nil,
75+
Name: string(path),
76+
Value: content,
77+
}
78+
79+
root.Tree = &ast.Tree{
80+
Root: &root,
81+
Document: string(path),
82+
}
83+
return root.Tree
84+
}
85+
86+
func (p *Parser) addFromNode(parent *ast.Node, lineNum int, line string, baseImage string) *ast.Node {
87+
// Find the position of the base image in the line
88+
fromIndex := strings.Index(strings.ToLower(line), "from")
89+
imageStart := fromIndex + 4 // "FROM" length
90+
for imageStart < len(line) && line[imageStart] == ' ' {
91+
imageStart++ // Skip whitespace
92+
}
93+
endChar := imageStart + len(baseImage)
94+
95+
node := ast.Node{
96+
Line: lineNum,
97+
StartChar: imageStart,
98+
EndChar: endChar,
99+
DocOffset: int64(fromIndex),
100+
Parent: parent,
101+
Children: nil,
102+
Name: "FROM",
103+
Value: baseImage,
104+
Attributes: make(map[string]string),
105+
Tree: parent.Tree,
106+
}
107+
108+
parent.Add(&node)
109+
return &node
110+
}

ast/dockerfile/parser_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* © 2025 Snyk Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package dockerfile
18+
19+
import (
20+
"testing"
21+
22+
"github.com/rs/zerolog"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestParser_Parse_SingleFrom(t *testing.T) {
28+
logger := zerolog.Nop()
29+
parser := New(&logger)
30+
31+
content := `FROM ubuntu:20.04
32+
RUN apt-get update
33+
COPY . /app
34+
`
35+
tree := parser.Parse([]byte(content), "/test/Dockerfile")
36+
37+
require.NotNil(t, tree)
38+
assert.Equal(t, "/test/Dockerfile", tree.Document)
39+
assert.Equal(t, "/test/Dockerfile", tree.Root.Name)
40+
41+
// Should have one FROM node
42+
require.Len(t, tree.Root.Children, 1)
43+
fromNode := tree.Root.Children[0]
44+
assert.Equal(t, "FROM", fromNode.Name)
45+
assert.Equal(t, "ubuntu:20.04", fromNode.Value)
46+
assert.Equal(t, 0, fromNode.Line)
47+
}
48+
49+
func TestParser_Parse_MultipleFrom(t *testing.T) {
50+
logger := zerolog.Nop()
51+
parser := New(&logger)
52+
53+
content := `FROM node:14 as builder
54+
WORKDIR /app
55+
COPY package*.json ./
56+
RUN npm install
57+
58+
FROM nginx:alpine
59+
COPY --from=builder /app/dist /usr/share/nginx/html
60+
`
61+
tree := parser.Parse([]byte(content), "/test/Dockerfile")
62+
63+
require.NotNil(t, tree)
64+
65+
// Should have two FROM nodes
66+
require.Len(t, tree.Root.Children, 2)
67+
68+
firstFrom := tree.Root.Children[0]
69+
assert.Equal(t, "FROM", firstFrom.Name)
70+
assert.Equal(t, "node:14", firstFrom.Value)
71+
assert.Equal(t, 0, firstFrom.Line)
72+
73+
secondFrom := tree.Root.Children[1]
74+
assert.Equal(t, "FROM", secondFrom.Name)
75+
assert.Equal(t, "nginx:alpine", secondFrom.Value)
76+
assert.Equal(t, 5, secondFrom.Line)
77+
}
78+
79+
func TestParser_Parse_SkipScratch(t *testing.T) {
80+
logger := zerolog.Nop()
81+
parser := New(&logger)
82+
83+
content := `FROM scratch
84+
COPY --from=builder /app /app
85+
`
86+
tree := parser.Parse([]byte(content), "/test/Dockerfile")
87+
88+
require.NotNil(t, tree)
89+
90+
// Should have no FROM nodes (scratch is skipped)
91+
assert.Len(t, tree.Root.Children, 0)
92+
}
93+
94+
func TestParser_Parse_CaseInsensitive(t *testing.T) {
95+
logger := zerolog.Nop()
96+
parser := New(&logger)
97+
98+
content := `from ubuntu:20.04
99+
FROM alpine:3.14
100+
FrOm golang:1.19
101+
`
102+
tree := parser.Parse([]byte(content), "/test/Dockerfile")
103+
104+
require.NotNil(t, tree)
105+
106+
// Should have three FROM nodes
107+
require.Len(t, tree.Root.Children, 3)
108+
assert.Equal(t, "ubuntu:20.04", tree.Root.Children[0].Value)
109+
assert.Equal(t, "alpine:3.14", tree.Root.Children[1].Value)
110+
assert.Equal(t, "golang:1.19", tree.Root.Children[2].Value)
111+
}
112+
113+
func TestParser_Parse_WithWindowsLineEndings(t *testing.T) {
114+
logger := zerolog.Nop()
115+
parser := New(&logger)
116+
117+
content := "FROM ubuntu:20.04\r\nRUN apt-get update\r\n"
118+
tree := parser.Parse([]byte(content), "/test/Dockerfile")
119+
120+
require.NotNil(t, tree)
121+
122+
// Should have one FROM node
123+
require.Len(t, tree.Root.Children, 1)
124+
assert.Equal(t, "ubuntu:20.04", tree.Root.Children[0].Value)
125+
}

domain/ide/workspace/workspace.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func (w *Workspace) HandleConfigChange() {
106106
func sendPublishDiagnosticsForAllProducts(folder types.Folder) {
107107
folder.FilterAndPublishDiagnostics(product.ProductOpenSource)
108108
folder.FilterAndPublishDiagnostics(product.ProductInfrastructureAsCode)
109+
folder.FilterAndPublishDiagnostics(product.ProductContainer)
109110
folder.FilterAndPublishDiagnostics(product.ProductCode)
110111
}
111112

domain/scanstates/scan_state_aggregator.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ func (agg *ScanStateAggregator) initForAllProducts(folderPath types.FilePath) {
136136
agg.referenceScanStates[folderProductKey{Product: product.ProductOpenSource, FolderPath: folderPath}] = &scanState{Status: NotStarted}
137137
agg.referenceScanStates[folderProductKey{Product: product.ProductCode, FolderPath: folderPath}] = &scanState{Status: NotStarted}
138138
agg.referenceScanStates[folderProductKey{Product: product.ProductInfrastructureAsCode, FolderPath: folderPath}] = &scanState{Status: NotStarted}
139+
agg.referenceScanStates[folderProductKey{Product: product.ProductContainer, FolderPath: folderPath}] = &scanState{Status: NotStarted}
139140

140141
agg.workingDirectoryScanStates[folderProductKey{Product: product.ProductOpenSource, FolderPath: folderPath}] = &scanState{Status: NotStarted}
141142
agg.workingDirectoryScanStates[folderProductKey{Product: product.ProductCode, FolderPath: folderPath}] = &scanState{Status: NotStarted}
142143
agg.workingDirectoryScanStates[folderProductKey{Product: product.ProductInfrastructureAsCode, FolderPath: folderPath}] = &scanState{Status: NotStarted}
144+
agg.workingDirectoryScanStates[folderProductKey{Product: product.ProductContainer, FolderPath: folderPath}] = &scanState{Status: NotStarted}
143145
}
144146

145147
// AddNewFolder adds new folder to the state scanstates map with initial NOT_STARTED state

domain/snyk/issues.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@ func (i *Issue) GetFilterableIssueType() product.FilterableIssueType {
415415
return product.FilterableIssueTypeOpenSource
416416
case product.ProductInfrastructureAsCode:
417417
return product.FilterableIssueTypeInfrastructureAsCode
418+
case product.ProductContainer:
419+
return product.FilterableIssueTypeContainer
418420
case product.ProductCode:
419421
switch i.IssueType {
420422
case types.CodeSecurityVulnerability:

0 commit comments

Comments
 (0)