diff --git a/ddtrace/tracer/option.go b/ddtrace/tracer/option.go index 1f9acf9dfc..aa1b0459ea 100644 --- a/ddtrace/tracer/option.go +++ b/ddtrace/tracer/option.go @@ -26,6 +26,7 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" "gopkg.in/DataDog/dd-trace-go.v1/internal" + appsecconfig "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/config" "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" "gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" @@ -114,6 +115,9 @@ type config struct { // debug, when true, writes details to logs. debug bool + // appsecStartOptions controls the options used when starting appsec features. + appsecStartOptions []appsecconfig.StartOption + // agent holds the capabilities of the agent and determines some // of the behaviour of the tracer. agent agentFeatures @@ -660,7 +664,7 @@ func loadAgentFeatures(agentDisabled bool, agentURL *url.URL, httpClient *http.C } defer resp.Body.Close() type agentConfig struct { - defaultEnv string `json:"default_env"` + DefaultEnv string `json:"default_env"` } type infoResponse struct { Endpoints []string `json:"endpoints"` @@ -762,6 +766,25 @@ func withNoopStats() StartOption { } } +// WithAppSecEnabled specifies whether AppSec features should be activated +// or not. +// +// By default, AppSec features are enabled if `DD_APPSEC_ENABLED` is set to a +// truthy value; and may be enabled by remote configuration if +// `DD_APPSEC_ENABLED` is not set at all. +// +// Using this option to explicitly disable appsec also prevents it from being +// remote activated. +func WithAppSecEnabled(enabled bool) StartOption { + mode := appsecconfig.ForcedOff + if enabled { + mode = appsecconfig.ForcedOn + } + return func(c *config) { + c.appsecStartOptions = append(c.appsecStartOptions, appsecconfig.WithEnablementMode(mode)) + } +} + // WithFeatureFlags specifies a set of feature flags to enable. Please take into account // that most, if not all features flags are considered to be experimental and result in // unexpected bugs. diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 104e31eb1c..378c0ff418 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -181,7 +181,10 @@ func Start(opts ...StartOption) { // appsec.Start() may use the telemetry client to report activation, so it is // important this happens _AFTER_ startTelemetry() has been called, so the // client is appropriately configured. - appsec.Start(appsecConfig.WithRCConfig(cfg)) + appsecopts := make([]appsecConfig.StartOption, 0, len(t.config.appsecStartOptions)+1) + appsecopts = append(appsecopts, t.config.appsecStartOptions...) + appsecopts = append(appsecopts, appsecConfig.WithRCConfig(cfg)) + appsec.Start(appsecopts...) _ = t.hostname() // Prime the hostname cache } diff --git a/internal/appsec/appsec.go b/internal/appsec/appsec.go index 0dc042caa1..c04f8b3a00 100644 --- a/internal/appsec/appsec.go +++ b/internal/appsec/appsec.go @@ -39,32 +39,41 @@ func Start(opts ...config.StartOption) { telemetry := newAppsecTelemetry() defer telemetry.emit() + startConfig := config.NewStartConfig(opts...) + // AppSec can start either: - // 1. Manually thanks to DD_APPSEC_ENABLED + // 1. Manually thanks to DD_APPSEC_ENABLED (or via [config.WithEnablementMode]) // 2. Remotely when DD_APPSEC_ENABLED is undefined // Note: DD_APPSEC_ENABLED=false takes precedence over remote configuration // and enforces to have AppSec disabled. - enabled, set, err := config.IsEnabled() + mode, modeOrigin, err := startConfig.EnablementMode() if err != nil { logUnexpectedStartError(err) return } - if set { - telemetry.addEnvConfig("DD_APPSEC_ENABLED", enabled) + + switch modeOrigin { + case config.OriginEnvVar: + telemetry.addEnvConfig("DD_APPSEC_ENABLED", mode == config.ForcedOn) + if mode == config.ForcedOff { + log.Debug("appsec: disabled by the configuration: set the environment variable DD_APPSEC_ENABLED to true to enable it") + return + } + case config.OriginExplicitOption: + telemetry.addCodeConfig("WithEnablementMode", mode) } - // Check if AppSec is explicitly disabled - if set && !enabled { - log.Debug("appsec: disabled by the configuration: set the environment variable DD_APPSEC_ENABLED to true to enable it") + // In any case, if we're forced off, we no longer have any business here... + if mode == config.ForcedOff { return } // Check whether libddwaf - required for Threats Detection - is ok or not if ok, err := waf.Health(); !ok { // We need to avoid logging an error to APM tracing users who don't necessarily intend to enable appsec - if set { + if mode == config.ForcedOn { // DD_APPSEC_ENABLED is explicitly set so we log an error - log.Error("appsec: threats detection cannot be enabled for the following reasons: %vappsec: no security activities will be collected. Please contact support at https://docs.datadoghq.com/help/ for help.", err) + log.Error("appsec: threats detection cannot be enabled for the following reasons: %v\nappsec: no security activities will be collected. Please contact support at https://docs.datadoghq.com/help/ for help.", err) } else { // DD_APPSEC_ENABLED is not set so we cannot know what the intent is here, we must log a // debug message instead to avoid showing an error to APM-tracing-only users. @@ -74,14 +83,11 @@ func Start(opts ...config.StartOption) { } // From this point we know that AppSec is either enabled or can be enabled through remote config - cfg, err := config.NewConfig() + cfg, err := startConfig.NewConfig() if err != nil { logUnexpectedStartError(err) return } - for _, opt := range opts { - opt(cfg) - } appsec := newAppSec(cfg) // Start the remote configuration client @@ -90,7 +96,7 @@ func Start(opts ...config.StartOption) { log.Error("appsec: Remote config: disabled due to an instanciation error: %v", err) } - if !set { + if mode == config.RCStandby { // AppSec is not enforced by the env var and can be enabled through remote config log.Debug("appsec: %s is not set, appsec won't start until activated through remote configuration", config.EnvEnabled) if err := appsec.enableRemoteActivation(); err != nil { diff --git a/internal/appsec/config/config.go b/internal/appsec/config/config.go index 6ffcbafcf1..7d334017f2 100644 --- a/internal/appsec/config/config.go +++ b/internal/appsec/config/config.go @@ -50,7 +50,78 @@ const ( ) // StartOption is used to customize the AppSec configuration when invoked with appsec.Start() -type StartOption func(c *Config) +type StartOption func(c *StartConfig) + +type StartConfig struct { + // RC is the remote config client configuration to be used. + RC *remoteconfig.ClientConfig + // IsEnabled is a function that determines whether AppSec is enabled or not. When unset, the + // default [IsEnabled] function is used. + EnablementMode func() (EnablementMode, Origin, error) +} + +type EnablementMode int8 + +const ( + // ForcedOff is the mode where AppSec is forced to be disabled, not allowing remote activation. + ForcedOff EnablementMode = -1 + // RCStandby is the mode where AppSec is in stand-by, waiting remote activation. + RCStandby EnablementMode = 0 + // ForcedOn is the mode where AppSec is forced to be enabled. + ForcedOn EnablementMode = 1 +) + +type Origin uint8 + +const ( + // OriginDefault is the origin of configuration values not explicitly set by the user in any way. + OriginDefault Origin = iota + // OriginEnvVar is the origin of configuration values set through environment variables. + OriginEnvVar + // OriginExplicitOption is the origin of configuration values set though explicit options in code. + OriginExplicitOption +) + +func NewStartConfig(opts ...StartOption) *StartConfig { + c := &StartConfig{ + EnablementMode: func() (mode EnablementMode, origin Origin, err error) { + enabled, set, err := IsEnabledByEnvironment() + if set { + origin = OriginEnvVar + if enabled { + mode = ForcedOn + } else { + mode = ForcedOff + } + } else { + origin = OriginDefault + mode = RCStandby + } + return mode, origin, err + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// WithEnablementMode forces AppSec enablement, replacing the default initialization conditions +// implemented by [IsEnabledByEnvironment]. +func WithEnablementMode(mode EnablementMode) StartOption { + return func(c *StartConfig) { + c.EnablementMode = func() (EnablementMode, Origin, error) { + return mode, OriginExplicitOption, nil + } + } +} + +// WithRCConfig sets the AppSec remote config client configuration to the specified cfg +func WithRCConfig(cfg remoteconfig.ClientConfig) StartOption { + return func(c *StartConfig) { + c.RC = &cfg + } +} // Config is the AppSec configuration. type Config struct { @@ -94,17 +165,12 @@ func (set AddressSet) AnyOf(anyOf ...string) bool { return false } -// WithRCConfig sets the AppSec remote config client configuration to the specified cfg -func WithRCConfig(cfg remoteconfig.ClientConfig) StartOption { - return func(c *Config) { - c.RC = &cfg - } -} - -// IsEnabled returns true when appsec is enabled by the environment variable DD_APPSEC_ENABLED (as of strconv's boolean -// parsing rules). When false, it also returns whether the env var was actually set or not. -// In case of a parsing error, it returns a detailed error. -func IsEnabled() (enabled bool, set bool, err error) { +// IsEnabledByEnvironment returns true when appsec is enabled by the environment variable +// [EnvEnabled] being set to a truthy value, as well as whether the environment variable was set at +// all or not (so it is possible to distinguish between explicitly false, and false-by-default). +// If the [EnvEnabled] variable is set to a value that is not a valid boolean (according to +// [strconv.ParseBool]), it is considered false-y, and a detailed error is also returned. +func IsEnabledByEnvironment() (enabled bool, set bool, err error) { return parseBoolEnvVar(EnvEnabled) } @@ -123,7 +189,7 @@ func parseBoolEnvVar(env string) (enabled bool, set bool, err error) { } // NewConfig returns a fresh appsec configuration read from the env -func NewConfig() (*Config, error) { +func (c *StartConfig) NewConfig() (*Config, error) { rules, err := internal.RulesFromEnv() if err != nil { return nil, err @@ -141,5 +207,6 @@ func NewConfig() (*Config, error) { Obfuscator: internal.NewObfuscatorConfig(), APISec: internal.NewAPISecConfig(), RASP: internal.RASPEnabled(), + RC: c.RC, }, nil } diff --git a/internal/appsec/remoteconfig_test.go b/internal/appsec/remoteconfig_test.go index 908cea0793..a83e92bc11 100644 --- a/internal/appsec/remoteconfig_test.go +++ b/internal/appsec/remoteconfig_test.go @@ -8,6 +8,7 @@ package appsec import ( "encoding/json" "errors" + "fmt" "os" "reflect" "slices" @@ -30,7 +31,7 @@ func TestASMFeaturesCallback(t *testing.T) { } enabledPayload := []byte(`{"asm":{"enabled":true}}`) disabledPayload := []byte(`{"asm":{"enabled":false}}`) - cfg, err := config.NewConfig() + cfg, err := config.NewStartConfig().NewConfig() require.NoError(t, err) a := newAppSec(cfg) err = a.startRC() @@ -410,6 +411,26 @@ func TestRemoteActivationScenarios(t *testing.T) { require.False(t, found) }) + t.Run("WithEnablementMode(EnabledModeForcedOn)", func(t *testing.T) { + for _, envVal := range []string{"", "true", "false"} { + t.Run(fmt.Sprintf("DD_APPSEC_ENABLED=%s", envVal), func(t *testing.T) { + t.Setenv(config.EnvEnabled, envVal) + + remoteconfig.Reset() + Start(config.WithEnablementMode(config.ForcedOn), config.WithRCConfig(remoteconfig.DefaultClientConfig())) + defer Stop() + + require.True(t, Enabled()) + found, err := remoteconfig.HasCapability(remoteconfig.ASMActivation) + require.NoError(t, err) + require.False(t, found) + found, err = remoteconfig.HasProduct(rc.ProductASMFeatures) + require.NoError(t, err) + require.False(t, found) + }) + } + }) + t.Run("DD_APPSEC_ENABLED=false", func(t *testing.T) { t.Setenv(config.EnvEnabled, "false") Start(config.WithRCConfig(remoteconfig.DefaultClientConfig())) @@ -417,6 +438,19 @@ func TestRemoteActivationScenarios(t *testing.T) { require.Nil(t, activeAppSec) require.False(t, Enabled()) }) + + t.Run("WithEnablementMode(EnabledModeForcedOff)", func(t *testing.T) { + for _, envVal := range []string{"", "true", "false"} { + t.Run(fmt.Sprintf("DD_APPSEC_ENABLED=%s", envVal), func(t *testing.T) { + t.Setenv(config.EnvEnabled, envVal) + + Start(config.WithEnablementMode(config.ForcedOff), config.WithRCConfig(remoteconfig.DefaultClientConfig())) + defer Stop() + require.Nil(t, activeAppSec) + require.False(t, Enabled()) + }) + } + }) } func TestCapabilitiesAndProducts(t *testing.T) { @@ -829,7 +863,7 @@ func TestWafRCUpdate(t *testing.T) { } t.Run("toggle-blocking", func(t *testing.T) { - cfg, err := config.NewConfig() + cfg, err := config.NewStartConfig().NewConfig() require.NoError(t, err) wafHandle, err := waf.NewHandle(cfg.RulesManager.Latest, cfg.Obfuscator.KeyRegex, cfg.Obfuscator.ValueRegex) require.NoError(t, err) diff --git a/internal/appsec/telemetry.go b/internal/appsec/telemetry.go index 2b07117bd8..2a8b0b42d3 100644 --- a/internal/appsec/telemetry.go +++ b/internal/appsec/telemetry.go @@ -57,6 +57,14 @@ func (a *appsecTelemetry) addConfig(name string, value any) { a.configs = append(a.configs, telemetry.Configuration{Name: name, Value: value}) } +// addCodeConfig adds a new configuration entry to this telemetry event. +func (a *appsecTelemetry) addCodeConfig(name string, value any) { + if a == nil { + return + } + a.configs = append(a.configs, telemetry.Configuration{Name: name, Value: value, Origin: telemetry.OriginCode}) +} + // addEnvConfig adds a new envionment-sourced configuration entry to this event. func (a *appsecTelemetry) addEnvConfig(name string, value any) { if a == nil {