diff --git a/cliv2/cmd/cliv2/instrumentation.go b/cliv2/cmd/cliv2/instrumentation.go index c24ef5c2a4..2f4d258433 100644 --- a/cliv2/cmd/cliv2/instrumentation.go +++ b/cliv2/cmd/cliv2/instrumentation.go @@ -8,6 +8,7 @@ import ( "strings" "time" + cli_utils "github.com/snyk/cli/cliv2/internal/utils" "github.com/snyk/go-application-framework/pkg/analytics" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/instrumentation" @@ -43,8 +44,9 @@ func addRuntimeDetails(instrumentor analytics.InstrumentationCollector, ua netwo instrumentor.AddExtension("os-details", strings.TrimSpace(string(out))) } - if out, err := exec.Command("ldd", "--version").Output(); err == nil { - instrumentor.AddExtension("c-runtime-details", strings.TrimSpace(string(out))) + cRuntimeDetails := cli_utils.GetGlibcDetails(cli_utils.ParserGlibcFull()) + if cRuntimeDetails != "" { + instrumentor.AddExtension("c-runtime-details", cRuntimeDetails) } } diff --git a/cliv2/go.mod b/cliv2/go.mod index 900f9a6658..ad56022831 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -19,7 +19,7 @@ require ( github.com/snyk/cli-extension-sbom v0.0.0-20251113132837-5f6cc6d0cb26 github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7 github.com/snyk/error-catalog-golang-public v0.0.0-20251205100923-e93b06d4a6c6 - github.com/snyk/go-application-framework v0.0.0-20251218080318-c938eac5f436 + github.com/snyk/go-application-framework v0.0.0-20251218160325-1dc83cd91def github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 github.com/snyk/snyk-iac-capture v0.6.5 github.com/snyk/snyk-ls v0.0.0-20251218112222-8c5878cbac7d @@ -27,6 +27,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + golang.org/x/mod v0.29.0 ) require ( @@ -226,7 +227,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect diff --git a/cliv2/go.sum b/cliv2/go.sum index 441c180634..9ffbcd501c 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -1318,8 +1318,8 @@ github.com/snyk/dep-graph/go v0.0.0-20251128083058-1972edcff6cf h1:RZ3KGLcbH37DU github.com/snyk/dep-graph/go v0.0.0-20251128083058-1972edcff6cf/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc= github.com/snyk/error-catalog-golang-public v0.0.0-20251205100923-e93b06d4a6c6 h1:8fGb/ipxqcmQHwC1ltd7Fch23fbQXP6kNohORe/GQpE= github.com/snyk/error-catalog-golang-public v0.0.0-20251205100923-e93b06d4a6c6/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4= -github.com/snyk/go-application-framework v0.0.0-20251218080318-c938eac5f436 h1:G7gu1qgxsGnh79VCsik98wKV7kmUS4RQnV+YrKlyxgc= -github.com/snyk/go-application-framework v0.0.0-20251218080318-c938eac5f436/go.mod h1:T+dt4+4XFAJ4PmoGgt/hrx7LiY+vaz+m9V4UYe24Rpc= +github.com/snyk/go-application-framework v0.0.0-20251218160325-1dc83cd91def h1:4VHOC7ccmL5D718oUR6DECeCbbC16iWe7jI3OGLEh+0= +github.com/snyk/go-application-framework v0.0.0-20251218160325-1dc83cd91def/go.mod h1:T+dt4+4XFAJ4PmoGgt/hrx7LiY+vaz+m9V4UYe24Rpc= github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 h1:CEQuYv0Go6MEyRCD3YjLYM2u3Oxkx8GpCpFBd4rUTUk= github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65/go.mod h1:88KbbvGYlmLgee4OcQ19yr0bNpXpOr2kciOthaSzCAg= github.com/snyk/policy-engine v1.1.0 h1:vFbFZbs3B0Y3XuGSur5om2meo4JEcCaKfNzshZFGOUs= diff --git a/cliv2/internal/utils/glibcversion.go b/cliv2/internal/utils/glibcversion.go new file mode 100644 index 0000000000..1f350e3fb8 --- /dev/null +++ b/cliv2/internal/utils/glibcversion.go @@ -0,0 +1,69 @@ +package utils + +import ( + "fmt" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" +) + +var ( + cachedVersion string + versionDetectOnce sync.Once + versionRegex = regexp.MustCompile(`(\d+\.\d+)`) +) + +type GlibcParsers func(string) (string, error) + +// DefaultGlibcVersion attempts to detect the glibc version on Linux systems +// The detection is performed only once and cached for subsequent calls +func DefaultGlibcVersion() string { + versionDetectOnce.Do(func() { + cachedVersion = GetGlibcDetails(ParserGlibcVersion()) + }) + return cachedVersion +} + +// GetGlibcDetails attempts to detect the glibc version on Linux systems +func GetGlibcDetails(parser GlibcParsers) string { + if runtime.GOOS != "linux" { + return "" + } + + // Method 1: Try ldd --version + if out, err := exec.Command("ldd", "--version").Output(); err == nil { + lines := strings.Split(string(out), "\n") + if len(lines) > 0 { + if result, err := parser(lines[0]); err == nil { + return result + } + } + } + + // Method 2: Try getconf GNU_LIBC_VERSION + if out, err := exec.Command("getconf", "GNU_LIBC_VERSION").Output(); err == nil { + if result, err := parser(string(out)); err == nil { + return result + } + } + + return "" +} + +func ParserGlibcFull() GlibcParsers { + return func(details string) (string, error) { + return strings.TrimSpace(details), nil + } +} + +func ParserGlibcVersion() GlibcParsers { + return func(details string) (string, error) { + if matches := versionRegex.FindStringSubmatch(details); len(matches) > 1 { + return matches[1], nil + } + + return "", fmt.Errorf("failed to parse glibc version") + } +} diff --git a/cliv2/internal/utils/glibcversion_test.go b/cliv2/internal/utils/glibcversion_test.go new file mode 100644 index 0000000000..754dd5ac1e --- /dev/null +++ b/cliv2/internal/utils/glibcversion_test.go @@ -0,0 +1,152 @@ +package utils + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ParserGlibcVersion(t *testing.T) { + testCases := []struct { + name string + input string + expectedVer string + expectError bool + }{ + { + name: "Standard ldd output format", + input: "ldd (GNU libc) 2.31", + expectedVer: "2.31", + expectError: false, + }, + { + name: "Standard ldd output format with extra info", + input: "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35", + expectedVer: "2.35", + expectError: false, + }, + { + name: "getconf format", + input: "glibc 2.28", + expectedVer: "2.28", + expectError: false, + }, + { + name: "Version at start of line", + input: "2.27 (GNU libc)", + expectedVer: "2.27", + expectError: false, + }, + { + name: "Multi-digit minor version", + input: "ldd (GNU libc) 2.117", + expectedVer: "2.117", + expectError: false, + }, + { + name: "Invalid format - no version", + input: "musl libc (x86_64)", + expectedVer: "", + expectError: true, + }, + { + name: "Invalid format - empty string", + input: "", + expectedVer: "", + expectError: true, + }, + { + name: "Invalid format - only text", + input: "some random text", + expectedVer: "", + expectError: true, + }, + { + name: "Invalid format - single number", + input: "version 2", + expectedVer: "", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parser := ParserGlibcVersion() + result, err := parser(tc.input) + + if tc.expectError { + assert.Error(t, err) + assert.Equal(t, "", result) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedVer, result) + } + }) + } +} + +func Test_ParserGlibcFull(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Standard ldd output", + input: "ldd (GNU libc) 2.31", + expected: "ldd (GNU libc) 2.31", + }, + { + name: "Output with leading whitespace", + input: " ldd (GNU libc) 2.31", + expected: "ldd (GNU libc) 2.31", + }, + { + name: "Output with trailing whitespace", + input: "ldd (GNU libc) 2.31 \n", + expected: "ldd (GNU libc) 2.31", + }, + { + name: "Output with both leading and trailing whitespace", + input: " \t ldd (GNU libc) 2.31 \n\t ", + expected: "ldd (GNU libc) 2.31", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Only whitespace", + input: " \n\t ", + expected: "", + }, + { + name: "Multiline output - only first matters", + input: "ldd (GNU libc) 2.31\nCopyright info\nMore text", + expected: "ldd (GNU libc) 2.31\nCopyright info\nMore text", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parser := ParserGlibcFull() + result, err := parser(tc.input) + + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +func Test_GetGlibcDetails_NonLinux(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Test only applicable on non-Linux") + } + + parser := ParserGlibcVersion() + result := GetGlibcDetails(parser) + + assert.Equal(t, "", result, "Should return empty string on non-Linux") +} diff --git a/cliv2/internal/utils/helpers.go b/cliv2/internal/utils/helpers.go index 8497b4c3a3..2df88d48a6 100644 --- a/cliv2/internal/utils/helpers.go +++ b/cliv2/internal/utils/helpers.go @@ -1,5 +1,11 @@ package utils +import ( + "strings" + + "golang.org/x/mod/semver" +) + // Dedupe removes duplicate entries from a given slice. // Returns a new, deduplicated slice. // @@ -36,3 +42,22 @@ func Contains(list []string, element string) bool { } return false } + +// SemverCompare compares two semantic version strings +func SemverCompare(v1 string, v2 string) int { + // ensure v1 and v2 start with "v" + if !strings.HasPrefix(v1, "v") { + v1 = "v" + v1 + } + if !strings.HasPrefix(v2, "v") { + v2 = "v" + v2 + } + + if !semver.IsValid(v1) || !semver.IsValid(v2) { + // return 0 to comply with semver.Compare() + // "Falls back to 0 when either version is invalid semver." + return 0 + } + + return semver.Compare(v1, v2) +} diff --git a/cliv2/pkg/basic_workflows/legacycli.go b/cliv2/pkg/basic_workflows/legacycli.go index 16c2c48657..0692050d8d 100644 --- a/cliv2/pkg/basic_workflows/legacycli.go +++ b/cliv2/pkg/basic_workflows/legacycli.go @@ -3,12 +3,17 @@ package basic_workflows import ( "bufio" "bytes" + "fmt" "io" "os" "os/exec" + "runtime" "strconv" "github.com/snyk/cli/cliv2/internal/proxy/interceptor" + "github.com/snyk/cli/cliv2/internal/utils" + "github.com/snyk/error-catalog-golang-public/snyk" + "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/pkg/errors" "github.com/rs/zerolog" @@ -27,7 +32,9 @@ var DATATYPEID_LEGACY_CLI_STDOUT workflow.Identifier = workflow.NewTypeIdentifie var staticNodeJsBinary string // injected by Makefile const ( - PROXY_NOAUTH string = "proxy-noauth" + PROXY_NOAUTH string = "proxy-noauth" + MIN_GLIBC_VERSION_LINUX_AMD64 string = "2.28" + MIN_GLIBC_VERSION_LINUX_ARM64 string = "2.31" ) func initLegacycli(engine workflow.Engine) error { @@ -75,6 +82,11 @@ func legacycliWorkflow( debugLoggerDefault := invocation.GetLogger() // uses log ri := invocation.GetRuntimeInfo() + err = ValidateGlibcVersion(debugLogger, utils.DefaultGlibcVersion(), runtime.GOOS, runtime.GOARCH) + if err != nil { + return output, err + } + args := config.GetStringSlice(configuration.RAW_CMD_ARGS) useStdIo := config.GetBool(configuration.WORKFLOW_USE_STDIO) workingDirectory := config.GetString(configuration.WORKING_DIRECTORY) @@ -189,3 +201,35 @@ func createInternalProxy(config configuration.Configuration, debugLogger *zerolo return wrapperProxy, nil } + +// ValidateGlibcVersion checks if the glibc version is supported and returns an Error Catalog error if it is not. +// This check only applies to glibc-based Linux systems (amd64, arm64). +func ValidateGlibcVersion(debugLogger *zerolog.Logger, glibcVersion string, os string, arch string) error { + // Skip validation on non-Linux or if glibc not detected + if glibcVersion == "" || os != "linux" { + return nil + } + + var minVersion string + switch arch { + case "arm64": + minVersion = MIN_GLIBC_VERSION_LINUX_ARM64 + case "amd64": + minVersion = MIN_GLIBC_VERSION_LINUX_AMD64 + default: + return nil + } + + res := utils.SemverCompare(glibcVersion, minVersion) + + if res < 0 { + return snyk.NewRequirementsNotMetError( + fmt.Sprintf("The installed glibc version, %s is not supported. Upgrade to a version of glibc >= %s", glibcVersion, minVersion), + snyk_errors.WithLinks([]string{"https://docs.snyk.io/developer-tools/snyk-cli/releases-and-channels-for-the-snyk-cli#runtime-requirements"}), + ) + } + + // We currently do not fail on Linux when glibc is not detected, which could lead to an ungraceful failure. + // Failing here would require detectGlibcVersion to always return a valid version, which is not the case. + return nil +} diff --git a/cliv2/pkg/basic_workflows/legacycli_test.go b/cliv2/pkg/basic_workflows/legacycli_test.go index 7d708badad..25c5a107a6 100644 --- a/cliv2/pkg/basic_workflows/legacycli_test.go +++ b/cliv2/pkg/basic_workflows/legacycli_test.go @@ -2,19 +2,25 @@ package basic_workflows import ( "context" + "errors" "fmt" + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "testing" + "github.com/golang/mock/gomock" "github.com/rs/zerolog" - "github.com/snyk/cli/cliv2/internal/proxy" + "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/mocks" "github.com/snyk/go-application-framework/pkg/networking" - "net/http" - "net/http/httptest" - "net/url" - "testing" + + "github.com/snyk/cli/cliv2/internal/proxy" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_finalizeArguments(t *testing.T) { @@ -92,3 +98,136 @@ func Test_proxyWithErrorHandler(t *testing.T) { }) } } + +func Test_ValidateGlibcVersion_doesNotApplyOnNonLinux(t *testing.T) { + // skip for Linux + if runtime.GOOS == "linux" { + t.Skip("Test only applicable on non-Linux") + } + + logger := zerolog.Nop() + err := ValidateGlibcVersion(&logger, "", "darwin", "amd64") + assert.NoError(t, err) +} + +func Test_ValidateGlibcVersion_validation(t *testing.T) { + t.Parallel() + + logger := zerolog.Nop() + + type glibcTest struct { + name string + version string + os string + arch string + expectedSnykErrCode string + } + + t.Run("validates successfully", func(t *testing.T) { + t.Parallel() + + tests := []glibcTest{ + { + name: "version exactly minimum on amd64", + version: MIN_GLIBC_VERSION_LINUX_AMD64, + os: "linux", + arch: "amd64", + }, + { + name: "version newer than minimum on amd64", + version: "2.35", + os: "linux", + arch: "amd64", + }, + { + name: "version exactly minimum on arm64", + version: MIN_GLIBC_VERSION_LINUX_ARM64, + os: "linux", + arch: "arm64", + }, + { + name: "version newer than minimum on arm64", + version: "2.35", + os: "linux", + arch: "arm64", + }, + { + name: "invalid version format", + version: "glibc version", // not a valid semver string + os: "linux", + arch: "amd64", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualErr := ValidateGlibcVersion(&logger, tt.version, tt.os, tt.arch) + + assert.NoError(t, actualErr) + }) + } + }) + + t.Run("returns error", func(t *testing.T) { + t.Parallel() + + testCases := []glibcTest{ + { + name: "version too old on amd64", + version: "2.27", // below minimum of 2.28 + os: "linux", + arch: "amd64", + expectedSnykErrCode: "SNYK-0010", + }, + { + name: "version too old on arm64", + version: "2.30", // below minimum of 2.31 + os: "linux", + arch: "arm64", + expectedSnykErrCode: "SNYK-0010", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualErr := ValidateGlibcVersion(&logger, tc.version, tc.os, tc.arch) + require.NotNil(t, actualErr, "Expected error but got nil") + var snykErr snyk_errors.Error + require.True(t, errors.As(actualErr, &snykErr), "Expected snyk_errors.Error but got: %v", actualErr) + assert.Equal(t, tc.expectedSnykErrCode, snykErr.ErrorCode) + }) + } + }) + + t.Run("returns nil", func(t *testing.T) { + t.Parallel() + + testCases := []glibcTest{ + { + name: "invalid os", + version: "", + os: "windows", + arch: "amd64", + }, + { + name: "invalid arch", + version: "", + os: "linux", + arch: "riscv", + }, + { + name: "musl/Alpine", + version: "", + os: "linux", + arch: "amd64", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualErr := ValidateGlibcVersion(&logger, tc.version, tc.os, tc.arch) + require.Nil(t, actualErr, "Expected no error but got: %v", actualErr) + }) + } + }) +}