diff --git a/cliv2-private/go.mod b/cliv2-private/go.mod index 717e170210..789c0f859a 100644 --- a/cliv2-private/go.mod +++ b/cliv2-private/go.mod @@ -222,7 +222,7 @@ require ( github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea // indirect github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 // indirect github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 // indirect - github.com/snyk/go-application-framework v0.14.3 // indirect + github.com/snyk/go-application-framework v0.15.0 // indirect github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc // indirect github.com/snyk/policy-engine v1.1.4 // indirect github.com/snyk/snyk-iac-capture v0.6.5 // indirect diff --git a/cliv2-private/go.sum b/cliv2-private/go.sum index e52faf9efa..8552536cfd 100644 --- a/cliv2-private/go.sum +++ b/cliv2-private/go.sum @@ -592,8 +592,8 @@ github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 h1:kgZNQ5ztI4+n3 github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 h1:XUPFP85nBh+zDCTvxxBuouZP9yG7H1qXZiMGFVMWVKM= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6/go.mod h1:0dz+HUR/r7VLlQpLfF0a/F1tdHH84NLTZzCxjZ+Q1nk= -github.com/snyk/go-application-framework v0.14.3 h1:uZA73qFLmBBL4Y3p4VG5pkr2ys8ojiBIPq0AXJhx7yk= -github.com/snyk/go-application-framework v0.14.3/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= +github.com/snyk/go-application-framework v0.15.0 h1:7OM9Lt5aB43iGMo6vAd6E+WUMogDUrO5WNgmzfxNfgA= +github.com/snyk/go-application-framework v0.15.0/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc h1:tuZVhmJFxS4qJlwYIIIw8xgw3VaVqIR3IAV0WaaFVnI= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc/go.mod h1:f42qLL7WXOS0od7dXJV/hK3myjms/r6HsXgLrg1HRRY= github.com/snyk/policy-engine v1.1.4 h1:0XpaMpl7ixSk4+dlpHYg2iKEBuv+5Ci+QIcbsmhktao= diff --git a/cliv2/go.mod b/cliv2/go.mod index 5f5e6ffc9c..6b32b9b360 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -22,7 +22,7 @@ require ( github.com/snyk/code-client-go v1.31.3 github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 - github.com/snyk/go-application-framework v0.14.3 + github.com/snyk/go-application-framework v0.15.0 github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc github.com/snyk/snyk-iac-capture v0.6.5 github.com/snyk/snyk-ls v0.0.0-20260814112015-c5325e836868 diff --git a/cliv2/go.sum b/cliv2/go.sum index 0a5c6e4880..eb1735b50e 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -542,8 +542,8 @@ github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 h1:kgZNQ5ztI4+n3 github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 h1:XUPFP85nBh+zDCTvxxBuouZP9yG7H1qXZiMGFVMWVKM= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6/go.mod h1:0dz+HUR/r7VLlQpLfF0a/F1tdHH84NLTZzCxjZ+Q1nk= -github.com/snyk/go-application-framework v0.14.3 h1:uZA73qFLmBBL4Y3p4VG5pkr2ys8ojiBIPq0AXJhx7yk= -github.com/snyk/go-application-framework v0.14.3/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= +github.com/snyk/go-application-framework v0.15.0 h1:7OM9Lt5aB43iGMo6vAd6E+WUMogDUrO5WNgmzfxNfgA= +github.com/snyk/go-application-framework v0.15.0/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc h1:tuZVhmJFxS4qJlwYIIIw8xgw3VaVqIR3IAV0WaaFVnI= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc/go.mod h1:f42qLL7WXOS0od7dXJV/hK3myjms/r6HsXgLrg1HRRY= github.com/snyk/policy-engine v1.1.4 h1:0XpaMpl7ixSk4+dlpHYg2iKEBuv+5Ci+QIcbsmhktao= diff --git a/cliv2/pkg/core/instrumentation.go b/cliv2/pkg/core/instrumentation.go index 0560d9dc77..a1d225495f 100644 --- a/cliv2/pkg/core/instrumentation.go +++ b/cliv2/pkg/core/instrumentation.go @@ -57,8 +57,14 @@ func addNetworkingDetails(instrumentor analytics.InstrumentationCollector, confi instrumentor.AddExtension("network-request-attempts", config.GetInt(middleware.ConfigurationKeyRequestAttempts)) } +// clientMachineIdConfigKey is the config key Studio sets before exec'ing the snyk +// binary (see Test_addClientMachineId). populateRedactionTerms in main.go must +// exclude this value from its sweep, or the scrub chokepoint redacts it right back +// out of the studio::client_machine_id extension it's meant to carry. +const clientMachineIdConfigKey = "internal_snyk_client_machine_id" + func addClientMachineId(instrumentor analytics.InstrumentationCollector, config configuration.Configuration) { - if id := config.GetString("internal_snyk_client_machine_id"); id != "" { + if id := config.GetString(clientMachineIdConfigKey); id != "" { instrumentor.AddExtension("studio::client_machine_id", id) } } @@ -133,7 +139,7 @@ func sendInstrumentation(ctx context.Context, eng workflow.Engine, instrumentor } logger.Print("Sending Instrumentation") - data, err := analytics.GetV2InstrumentationObject(instrumentor, analytics.WithLogger(logger)) + data, err := analytics.GetV2InstrumentationObject(instrumentor, analytics.WithLogger(logger), analytics.WithConfiguration(eng.GetConfiguration())) if err != nil { logger.Err(err).Msg("Failed to derive data object") } diff --git a/cliv2/pkg/core/instrumentation_test.go b/cliv2/pkg/core/instrumentation_test.go index e3657fabb9..8691afdd21 100644 --- a/cliv2/pkg/core/instrumentation_test.go +++ b/cliv2/pkg/core/instrumentation_test.go @@ -1,10 +1,16 @@ package core import ( + "context" "testing" + "github.com/golang/mock/gomock" + "github.com/rs/zerolog" "github.com/snyk/go-application-framework/pkg/analytics" "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/mocks" + "github.com/snyk/go-application-framework/pkg/workflow" "github.com/stretchr/testify/assert" ) @@ -27,6 +33,41 @@ func Test_shallSendInstrumentation(t *testing.T) { assert.False(t, actual) } +func Test_sendInstrumentation_passesEngineConfigurationToInstrumentationObject(t *testing.T) { + globalConfiguration = configuration.NewWithOpts(configuration.WithAutomaticEnv()) + + mockController := gomock.NewController(t) + mockEngine := mocks.NewMockEngine(mockController) + + // Mirrors production: populateRedactionTerms runs at startup and sweeps up any + // os.Environ() value it doesn't recognize. The client machine id is real Studio + // data, not a secret, so its own env var value must not end up in the terms + // this test's later scrub pass redacts against. + machineId := "studio-device-id-abc12345" + t.Setenv("INTERNAL_SNYK_CLIENT_MACHINE_ID", machineId) + engineConfig := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + mockEngine.EXPECT().GetWorkflows().Return([]workflow.Identifier{}) + populateRedactionTerms(engineConfig, mockEngine) + + // One call from shallSendInstrumentation, one to derive analytics.WithConfiguration. + // If the call site regresses to only passing WithLogger, this expectation goes unmet. + mockEngine.EXPECT().GetConfiguration().Return(engineConfig).Times(2) + mockEngine.EXPECT().Invoke(localworkflows.WORKFLOWID_REPORT_ANALYTICS, gomock.Any(), gomock.Any()).Return(nil, nil) + + instrumentor := analytics.NewInstrumentationCollector() + addClientMachineId(instrumentor, engineConfig) + logger := zerolog.Nop() + + sendInstrumentation(context.Background(), mockEngine, instrumentor, &logger) + + // sendInstrumentation just ran the extension through the same scrub chokepoint; + // re-deriving the object (a pure read, doesn't mutate the collector) proves the + // machine id survived it rather than coming back "***". + obj, err := analytics.GetV2InstrumentationObject(instrumentor, analytics.WithConfiguration(engineConfig)) + assert.NoError(t, err) + assert.Equal(t, machineId, (*obj.Data.Attributes.Interaction.Extension)["studio::client_machine_id"]) +} + func Test_addClientMachineId(t *testing.T) { t.Run("emits studio::client_machine_id when INTERNAL_SNYK_CLIENT_MACHINE_ID env var is set", func(t *testing.T) { // Mirrors how Studio sets the env var before exec'ing the snyk binary diff --git a/cliv2/pkg/core/main.go b/cliv2/pkg/core/main.go index bc832ba635..e3c681e5b7 100644 --- a/cliv2/pkg/core/main.go +++ b/cliv2/pkg/core/main.go @@ -38,6 +38,7 @@ import ( "github.com/snyk/cli/cliv2/internal/constants" persona "github.com/snyk/cli/cliv2/internal/persona" + "github.com/snyk/cli/cliv2/internal/persona/agent" cliv2utils "github.com/snyk/cli/cliv2/internal/utils" localworkflows "github.com/snyk/go-application-framework/pkg/local_workflows" @@ -637,12 +638,13 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { // init engine err = globalEngine.Init() + // Unconditional so the analytics scrub chokepoint (which reads logging.REDACTION_TERMS + // off of config) sees these terms even on non-debug runs, not just when the debug log itself is scrubbed. + termsToRedact := populateRedactionTerms(globalConfiguration, globalEngine) + // We want to scrub the debug log of sensitive information. Since we have a list of commands we know can occur, we can intersect that with arguments we don't recognize, and automatically scrub all those from the logs. if debugEnabled { writeLogHeader(globalConfiguration, networkAccess) - knownTerms, _ := instrumentation.GetKnownCommandsAndFlags(globalEngine) - knownTerms = append(knownTerms, globalConfiguration.GetString(configuration.API_URL), globalConfiguration.GetString(configuration.ORGANIZATION), globalConfiguration.GetString(configuration.ORGANIZATION_SLUG)) - termsToRedact := cliv2utils.GetUnknownParameters(os.Args[1:], os.Environ(), knownTerms) scrubbedLogger.AddTermsToReplace(termsToRedact) } @@ -714,6 +716,24 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { return finalExitCode } +// populateRedactionTerms computes likely-secret literal values (unrecognized CLI +// arguments and environment variables) and records them on config under +// logging.REDACTION_TERMS, so the analytics scrub chokepoint can redact +// them regardless of whether debug logging is enabled. +func populateRedactionTerms(config configuration.Configuration, engine workflow.Engine) []string { + knownTerms, _ := instrumentation.GetKnownCommandsAndFlags(engine) + knownTerms = append(knownTerms, config.GetString(configuration.API_URL), config.GetString(configuration.ORGANIZATION), config.GetString(configuration.ORGANIZATION_SLUG), config.GetString(clientMachineIdConfigKey)) + // AI_AGENT is trusted verbatim into the persona.agent extension (see + // agent.canonicalAgent) for any harness not on its short canonical list, so its + // raw value needs the same exclusion as the client machine id above. + if detectedAgent, ok := agent.DetectAgent(); ok { + knownTerms = append(knownTerms, string(detectedAgent)) + } + termsToRedact := cliv2utils.GetUnknownParameters(os.Args[1:], os.Environ(), knownTerms) + config.Set(logging.REDACTION_TERMS, termsToRedact) + return termsToRedact +} + func processError(err error, errorList []error) ([]error, error) { // ensure to use generic fallback error catalog error if no other is available resultError := decorateError(err) diff --git a/cliv2/pkg/core/main_test.go b/cliv2/pkg/core/main_test.go index 8d10a60ae4..cbd6c48c07 100644 --- a/cliv2/pkg/core/main_test.go +++ b/cliv2/pkg/core/main_test.go @@ -23,6 +23,7 @@ import ( "github.com/snyk/go-application-framework/pkg/local_workflows/content_type" "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/logging" "github.com/snyk/go-application-framework/pkg/mocks" "github.com/snyk/go-application-framework/pkg/utils/ufm" "github.com/snyk/go-application-framework/pkg/workflow" @@ -68,6 +69,52 @@ func Test_mainWithErrorCode(t *testing.T) { }) } +func Test_populateRedactionTerms(t *testing.T) { + mockController := gomock.NewController(t) + mockEngine := mocks.NewMockEngine(mockController) + mockEngine.EXPECT().GetWorkflows().Return(nil) + + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + t.Setenv("SNYK_TEST_REDACTION_MARKER", "unmistakably-secret-value") + + // No debugEnabled anywhere in this call: populateRedactionTerms runs + // unconditionally at its call site, so proving it sets config here proves + // the behavior holds regardless of debugEnabled. + terms := populateRedactionTerms(config, mockEngine) + + assert.Contains(t, terms, "unmistakably-secret-value") + assert.Equal(t, terms, config.GetStringSlice(logging.REDACTION_TERMS)) +} + +func Test_populateRedactionTerms_excludesClientMachineId(t *testing.T) { + mockController := gomock.NewController(t) + mockEngine := mocks.NewMockEngine(mockController) + mockEngine.EXPECT().GetWorkflows().Return(nil) + + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + machineId := "studio-device-id-abc12345" + t.Setenv("INTERNAL_SNYK_CLIENT_MACHINE_ID", machineId) + + terms := populateRedactionTerms(config, mockEngine) + + assert.NotContains(t, terms, machineId, "client machine id must never be swept into REDACTION_TERMS, or the analytics scrub chokepoint strips it right back out of its own extension") +} + +func Test_populateRedactionTerms_excludesDetectedAgent(t *testing.T) { + mockController := gomock.NewController(t) + mockEngine := mocks.NewMockEngine(mockController) + mockEngine.EXPECT().GetWorkflows().Return(nil) + + config := configuration.NewWithOpts(configuration.WithAutomaticEnv()) + // Not on agent.canonicalAgent's short-circuit list, so AI_AGENT is trusted + // verbatim into the persona.agent extension. + t.Setenv("AI_AGENT", "some-unlisted-harness") + + terms := populateRedactionTerms(config, mockEngine) + + assert.NotContains(t, terms, "some-unlisted-harness", "a caller-declared AI_AGENT value must never be swept into REDACTION_TERMS, or the analytics scrub chokepoint strips it right back out of the persona.agent extension") +} + func Test_initApplicationConfiguration_DisablesAnalytics(t *testing.T) { t.Run("via SNYK_DISABLE_ANALYTICS (true)", func(t *testing.T) { c := configuration.NewWithOpts(configuration.WithAutomaticEnv())