Skip to content

appsec: new user monitoring SDK telemetry #3312

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 23 additions & 14 deletions appsec/appsec.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@ import (
"github.com/DataDog/dd-trace-go/v2/internal/appsec"
"github.com/DataDog/dd-trace-go/v2/internal/appsec/emitter/usersec"
"github.com/DataDog/dd-trace-go/v2/internal/log"
"github.com/DataDog/dd-trace-go/v2/internal/telemetry"
)

var appsecDisabledLog sync.Once

type CollectionMode string

const (
CollectionModeSDK CollectionMode = "sdk"
)

// MonitorParsedHTTPBody runs the security monitoring rules on the given *parsed*
// HTTP request body and returns if the HTTP request is suspicious and configured to be blocked.
// The given context must be the HTTP request context as returned
Expand All @@ -52,20 +59,24 @@ func MonitorParsedHTTPBody(ctx context.Context, body any) error {
// APM tracer middleware on use according to your blocking configuration.
// This function always returns nil when appsec is disabled and doesn't block users.
func SetUser(ctx context.Context, id string, opts ...tracer.UserMonitoringOption) error {
return setUser(ctx, id, usersec.UserSet, opts)
return setUser(ctx, id, usersec.UserSet, CollectionModeSDK, opts)
}

func setUser(ctx context.Context, id string, userEventType usersec.UserEventType, opts []tracer.UserMonitoringOption) error {
func setUser(ctx context.Context, id string, userEventType usersec.UserEventType, collectionMode CollectionMode, opts []tracer.UserMonitoringOption) error {
s, ok := tracer.SpanFromContext(ctx)
if !ok {
log.Debug("appsec: could not retrieve span from context. User ID tag won't be set")
log.Debug("appsec: user event monitoring SDK: could not retrieve span from context. User ID tag won't be set")
return nil
}

tracer.SetUser(s, id, opts...)

// Record that the user collection mode is SDK
s.Root().SetTag("_dd.appsec.user.collection_mode", collectionMode)

if !appsec.Enabled() {
appsecDisabledLog.Do(func() { log.Warn("appsec: not enabled. User blocking checks won't be performed.") })
return nil
// Not returning here, as we still want to record the relevant span tags (just no WAF call).
}

op, errPtr := usersec.StartUserLoginOperation(ctx, userEventType, usersec.UserLoginOperationArgs{})
Expand Down Expand Up @@ -98,23 +109,21 @@ func setUser(ctx context.Context, id string, userEventType usersec.UserEventType
// the provided user ID is found to be on a configured deny list. See the
// documentation for [SetUser] for more information.
func TrackUserLoginSuccess(ctx context.Context, login string, uid string, md map[string]string, opts ...tracer.UserMonitoringOption) error {
telemetry.Count(telemetry.NamespaceAppSec, "sdk.event", []string{"event_type:login_success", "sdk_version:v2"}).Submit(1)

// We need to make sure the metadata contains the correct `usr.id` and
// `usr.login` values, so we clone the metadata map and set these two.
md = maps.Clone(md)
if md == nil {
md = make(map[string]string, 2)
}
md["usr.login"] = login
if uid == "" {
// Warn if no user ID was provided, as this is necessary for user blocking
// to be possible...
log.Error("appsec: TrackUserLoginSuccess requires a non-empty user ID (uid) in order for user blocking to be effective")
} else {
if uid != "" {
md["usr.id"] = uid
}

TrackCustomEvent(ctx, "users.login.success", md)
return setUser(ctx, uid, usersec.UserLoginSuccess, append(opts, tracer.WithUserLogin(login)))
return setUser(ctx, uid, usersec.UserLoginSuccess, CollectionModeSDK, append(opts, tracer.WithUserLogin(login)))
}

// TrackUserLoginFailure denotes a failed user login event, which is used by
Expand All @@ -132,9 +141,7 @@ func TrackUserLoginSuccess(ctx context.Context, login string, uid string, md map
//
// The provided metata is attached to the failed user login event.
func TrackUserLoginFailure(ctx context.Context, login string, exists bool, md map[string]string) {
if getRootSpan(ctx) == nil {
return
}
telemetry.Count(telemetry.NamespaceAppSec, "sdk.event", []string{"event_type:login_failure", "sdk_version:v2"}).Submit(1)

// We need to make sure the metadata contains the correct information
md = maps.Clone(md)
Expand All @@ -157,6 +164,8 @@ func TrackUserLoginFailure(ctx context.Context, login string, exists bool, md ma
// Such events trigger the backend-side events monitoring ultimately blocking
// the IP address and/or user id associated to them.
func TrackCustomEvent(ctx context.Context, name string, md map[string]string) {
telemetry.Count(telemetry.NamespaceAppSec, "sdk.event", []string{"event_type:custom", "sdk_version:v1"}).Submit(1)

span := getRootSpan(ctx)
if span == nil {
return
Expand All @@ -175,7 +184,7 @@ func TrackCustomEvent(ctx context.Context, name string, md map[string]string) {
func getRootSpan(ctx context.Context) *tracer.Span {
span, _ := tracer.SpanFromContext(ctx)
if span == nil {
log.Error("appsec: could not find a span in the given Go context")
log.Warn("appsec: user event monitoring SDK: could not find a span in the provided context.Context")
return nil
}
return span.Root()
Expand Down
97 changes: 93 additions & 4 deletions appsec/appsec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,23 @@ import (
"github.com/DataDog/dd-trace-go/v2/ddtrace/mocktracer"
"github.com/DataDog/dd-trace-go/v2/ddtrace/tracer"
privateAppsec "github.com/DataDog/dd-trace-go/v2/internal/appsec"
"github.com/DataDog/dd-trace-go/v2/internal/telemetry"
"github.com/DataDog/dd-trace-go/v2/internal/telemetry/telemetrytest"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestTrackUserLoginSuccess(t *testing.T) {

t.Run("nominal-with-metadata", func(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()

var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

span, ctx := tracer.StartSpanFromContext(context.Background(), "example")
appsec.TrackUserLoginSuccess(ctx, "user login", "user id", map[string]string{"region": "us-east-1"}, tracer.WithUserName("username"))
span.Finish()
Expand All @@ -42,12 +49,20 @@ func TestTrackUserLoginSuccess(t *testing.T) {
assertTag(t, finished, "usr.login", "user login")
assertTag(t, finished, expectedEventPrefix+"region", "us-east-1")
assertTag(t, finished, "usr.name", "username")

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_success,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})

t.Run("nominal-nil-metadata", func(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()

var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

span, ctx := tracer.StartSpanFromContext(context.Background(), "example")
appsec.TrackUserLoginSuccess(ctx, "user login", "user id", nil)
span.Finish()
Expand All @@ -62,18 +77,38 @@ func TestTrackUserLoginSuccess(t *testing.T) {
expectedEventPrefix := "appsec.events.users.login.success."
assertTag(t, finished, expectedEventPrefix+"track", "true")
assertTag(t, finished, "usr.id", "user id")

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_success,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})

t.Run("nil-context", func(t *testing.T) {
var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

require.NotPanics(t, func() {
//lint:ignore SA1012 we are intentionally passing a nil context to verify incorrect use does not lead to panic
appsec.TrackUserLoginSuccess(nil, "user login", "user id", map[string]string{"region": "us-east-1"}, tracer.WithUserName("username"))

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_success,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})
})

t.Run("empty-context", func(t *testing.T) {
var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

require.NotPanics(t, func() {
appsec.TrackUserLoginSuccess(context.Background(), "user login", "user id", map[string]string{"region": "us-east-1"}, tracer.WithUserName("username"))

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_success,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})
})
}
Expand All @@ -85,6 +120,10 @@ func TestTrackUserLoginFailure(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()

var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

span, ctx := tracer.StartSpanFromContext(context.Background(), "example")
appsec.TrackUserLoginFailure(ctx, "user login", userExists, map[string]string{"region": "us-east-1"})
span.Finish()
Expand All @@ -102,22 +141,42 @@ func TestTrackUserLoginFailure(t *testing.T) {
assertTag(t, finished, expectedEventPrefix+"usr.login", "user login")
assertTag(t, finished, expectedEventPrefix+"usr.exists", strconv.FormatBool(userExists))
assertTag(t, finished, expectedEventPrefix+"region", "us-east-1")

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_failure,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
}
}
t.Run("user-exists", test(true))
t.Run("user-not-exists", test(false))
})

t.Run("nil-context", func(t *testing.T) {
var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

require.NotPanics(t, func() {
//lint:ignore SA1012 we are intentionally passing a nil context to verify incorrect use does not lead to panic
appsec.TrackUserLoginFailure(nil, "user login", false, nil)

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_failure,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})
})

t.Run("empty-context", func(t *testing.T) {
var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

require.NotPanics(t, func() {
appsec.TrackUserLoginFailure(context.Background(), "user login", false, nil)

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:login_failure,sdk_version:v2"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})
})
}
Expand All @@ -127,6 +186,10 @@ func TestCustomEvent(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()

var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

span, ctx := tracer.StartSpanFromContext(context.Background(), "example")
md := map[string]string{"key-1": "value 1", "key-2": "value 2", "key-3": "value 3"}
appsec.TrackCustomEvent(ctx, "my-custom-event", md)
Expand All @@ -144,30 +207,56 @@ func TestCustomEvent(t *testing.T) {
for k, v := range md {
assertTag(t, finished, expectedEventPrefix+k, v)
}

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:custom,sdk_version:v1"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})

t.Run("nil-context", func(t *testing.T) {
var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

require.NotPanics(t, func() {
//lint:ignore SA1012 we are intentionally passing a nil context to verify incorrect use does not lead to panic
appsec.TrackCustomEvent(nil, "my-custom-event", nil)
})

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:custom,sdk_version:v1"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})

t.Run("empty-context", func(t *testing.T) {
var telemetryRecorder telemetrytest.RecordClient
restoreTelemetry := telemetry.MockClient(&telemetryRecorder)
defer restoreTelemetry()

require.NotPanics(t, func() {
appsec.TrackCustomEvent(context.Background(), "my-custom-event", nil)
})

metric := telemetryRecorder.Metrics[telemetrytest.MetricKey{Namespace: telemetry.NamespaceAppSec, Name: "sdk.event", Kind: "count", Tags: "event_type:custom,sdk_version:v1"}]
require.NotNil(t, metric)
assert.EqualValues(t, 1, metric.Get())
})
}

func TestSetUser(t *testing.T) {
t.Run("early-return/appsec-disabled", func(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()
span, ctx := tracer.StartSpanFromContext(context.Background(), "example")
defer span.Finish()
err := appsec.SetUser(ctx, "usr.id")
require.NoError(t, err)

func() {
span, ctx := tracer.StartSpanFromContext(context.Background(), "example")
defer span.Finish()
err := appsec.SetUser(ctx, "usr.id")
require.NoError(t, err)
}()

finished := mt.FinishedSpans()[0]
assertTag(t, finished, "_dd.appsec.user.collection_mode", "sdk")
})

privateAppsec.Start()
Expand Down
5 changes: 5 additions & 0 deletions appsec/appsecv1.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/DataDog/dd-trace-go/v2/ddtrace/tracer"
"github.com/DataDog/dd-trace-go/v2/internal/appsec/emitter/usersec"
"github.com/DataDog/dd-trace-go/v2/internal/telemetry"
)

// TrackUserLoginSuccessEvent sets a successful user login event, with the given
Expand All @@ -29,6 +30,8 @@ import (
// Deprecated: use [TrackUserLoginSuccess] instead. It requires collection of
// the user login, which is useful for detecting account takeover attacks.
func TrackUserLoginSuccessEvent(ctx context.Context, uid string, md map[string]string, opts ...tracer.UserMonitoringOption) error {
telemetry.Count(telemetry.NamespaceAppSec, "sdk.event", []string{"event_type:login_success", "sdk_version:v1"}).Submit(1)

login, _, _ := getMetadata(opts)
return TrackUserLoginSuccess(ctx, login, uid, md, opts...)
}
Expand All @@ -48,6 +51,8 @@ func TrackUserLoginSuccessEvent(ctx context.Context, uid string, md map[string]s
// which is what is available during a failed login attempt, instead of the user
// ID, which is oftern not (especially when the user does not exist).
func TrackUserLoginFailureEvent(ctx context.Context, uid string, exists bool, md map[string]string) {
telemetry.Count(telemetry.NamespaceAppSec, "sdk.event", []string{"event_type:login_failure", "sdk_version:v1"}).Submit(1)

span := getRootSpan(ctx)
if span == nil {
return
Expand Down
Loading
Loading