diff --git a/CODEOWNERS b/CODEOWNERS index 3328c09d71..b0c28372e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -14,3 +14,4 @@ # appsec /internal/appsec @DataDog/appsec-go +/contrib/**/appsec.go @DataDog/appsec-go diff --git a/contrib/go-chi/chi.v5/chi.go b/contrib/go-chi/chi.v5/chi.go index 9a5cae94d2..477f8e9636 100644 --- a/contrib/go-chi/chi.v5/chi.go +++ b/contrib/go-chi/chi.v5/chi.go @@ -31,6 +31,10 @@ func Middleware(opts ...Option) func(next http.Handler) http.Handler { log.Debug("contrib/go-chi/chi.v5: Configuring Middleware: %#v", cfg) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if cfg.ignoreRequest(r) { + next.ServeHTTP(w, r) + return + } opts := []ddtrace.StartSpanOption{ tracer.SpanType(ext.SpanTypeWeb), tracer.ServiceName(cfg.serviceName), diff --git a/contrib/go-chi/chi.v5/chi_test.go b/contrib/go-chi/chi.v5/chi_test.go index 1c90f06066..3cd1c3294e 100644 --- a/contrib/go-chi/chi.v5/chi_test.go +++ b/contrib/go-chi/chi.v5/chi_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" @@ -270,3 +271,33 @@ func TestAnalyticsSettings(t *testing.T) { assertRate(t, mt, 0.23, WithAnalyticsRate(0.23)) }) } + +func TestIgnoreRequest(t *testing.T) { + router := chi.NewRouter() + router.Use(Middleware( + WithIgnoreRequest(func(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, "/skip") + }), + )) + + router.Get("/ok", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + router.Get("/skip", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("skip")) + }) + + for path, shouldSkip := range map[string]bool{ + "/ok": false, + "/skip": true, + "/skipfoo": true, + } { + mt := mocktracer.Start() + defer mt.Reset() + + r := httptest.NewRequest("GET", "http://localhost"+path, nil) + router.ServeHTTP(httptest.NewRecorder(), r) + assert.Equal(t, shouldSkip, len(mt.FinishedSpans()) == 0) + } +} diff --git a/contrib/go-chi/chi.v5/option.go b/contrib/go-chi/chi.v5/option.go index f9a6b2aaa4..6c19dd3cc7 100644 --- a/contrib/go-chi/chi.v5/option.go +++ b/contrib/go-chi/chi.v5/option.go @@ -7,6 +7,7 @@ package chi import ( "math" + "net/http" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" "gopkg.in/DataDog/dd-trace-go.v1/internal" @@ -18,6 +19,7 @@ type config struct { spanOpts []ddtrace.StartSpanOption // additional span options to be applied analyticsRate float64 isStatusError func(statusCode int) bool + ignoreRequest func(r *http.Request) bool } // Option represents an option that can be passed to NewRouter. @@ -34,6 +36,7 @@ func defaults(cfg *config) { cfg.analyticsRate = globalconfig.AnalyticsRate() } cfg.isStatusError = isServerError + cfg.ignoreRequest = func(_ *http.Request) bool { return false } } // WithServiceName sets the given service name for the router. @@ -85,3 +88,11 @@ func WithStatusCheck(fn func(statusCode int) bool) Option { func isServerError(statusCode int) bool { return statusCode >= 500 && statusCode < 600 } + +// WithIgnoreRequest specifies a function to use for determining if the +// incoming HTTP request tracing should be skipped. +func WithIgnoreRequest(fn func(r *http.Request) bool) Option { + return func(cfg *config) { + cfg.ignoreRequest = fn + } +} diff --git a/contrib/go-chi/chi/chi.go b/contrib/go-chi/chi/chi.go index e4a90a961e..c5866dd146 100644 --- a/contrib/go-chi/chi/chi.go +++ b/contrib/go-chi/chi/chi.go @@ -31,6 +31,10 @@ func Middleware(opts ...Option) func(next http.Handler) http.Handler { log.Debug("contrib/go-chi/chi: Configuring Middleware: %#v", cfg) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if cfg.ignoreRequest(r) { + next.ServeHTTP(w, r) + return + } opts := []ddtrace.StartSpanOption{ tracer.SpanType(ext.SpanTypeWeb), tracer.ServiceName(cfg.serviceName), diff --git a/contrib/go-chi/chi/chi_test.go b/contrib/go-chi/chi/chi_test.go index c3972745e3..2fcf68977e 100644 --- a/contrib/go-chi/chi/chi_test.go +++ b/contrib/go-chi/chi/chi_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" @@ -270,3 +271,33 @@ func TestAnalyticsSettings(t *testing.T) { assertRate(t, mt, 0.23, WithAnalyticsRate(0.23)) }) } + +func TestIgnoreRequest(t *testing.T) { + router := chi.NewRouter() + router.Use(Middleware( + WithIgnoreRequest(func(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, "/skip") + }), + )) + + router.Get("/ok", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + router.Get("/skip", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("skip")) + }) + + for path, shouldSkip := range map[string]bool{ + "/ok": false, + "/skip": true, + "/skipfoo": true, + } { + mt := mocktracer.Start() + defer mt.Reset() + + r := httptest.NewRequest("GET", "http://localhost"+path, nil) + router.ServeHTTP(httptest.NewRecorder(), r) + assert.Equal(t, shouldSkip, len(mt.FinishedSpans()) == 0) + } +} diff --git a/contrib/go-chi/chi/option.go b/contrib/go-chi/chi/option.go index f9a6b2aaa4..6c19dd3cc7 100644 --- a/contrib/go-chi/chi/option.go +++ b/contrib/go-chi/chi/option.go @@ -7,6 +7,7 @@ package chi import ( "math" + "net/http" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" "gopkg.in/DataDog/dd-trace-go.v1/internal" @@ -18,6 +19,7 @@ type config struct { spanOpts []ddtrace.StartSpanOption // additional span options to be applied analyticsRate float64 isStatusError func(statusCode int) bool + ignoreRequest func(r *http.Request) bool } // Option represents an option that can be passed to NewRouter. @@ -34,6 +36,7 @@ func defaults(cfg *config) { cfg.analyticsRate = globalconfig.AnalyticsRate() } cfg.isStatusError = isServerError + cfg.ignoreRequest = func(_ *http.Request) bool { return false } } // WithServiceName sets the given service name for the router. @@ -85,3 +88,11 @@ func WithStatusCheck(fn func(statusCode int) bool) Option { func isServerError(statusCode int) bool { return statusCode >= 500 && statusCode < 600 } + +// WithIgnoreRequest specifies a function to use for determining if the +// incoming HTTP request tracing should be skipped. +func WithIgnoreRequest(fn func(r *http.Request) bool) Option { + return func(cfg *config) { + cfg.ignoreRequest = fn + } +} diff --git a/contrib/google.golang.org/grpc/appsec.go b/contrib/google.golang.org/grpc/appsec.go new file mode 100644 index 0000000000..cec9c2d424 --- /dev/null +++ b/contrib/google.golang.org/grpc/appsec.go @@ -0,0 +1,78 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package grpc + +import ( + "encoding/json" + "net" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo/instrumentation/grpcsec" + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo/instrumentation/httpsec" + + "golang.org/x/net/context" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +// UnaryHandler wrapper to use when AppSec is enabled to monitor its execution. +func appsecUnaryHandlerMiddleware(span ddtrace.Span, handler grpc.UnaryHandler) grpc.UnaryHandler { + httpsec.SetAppSecTags(span) + return func(ctx context.Context, req interface{}) (interface{}, error) { + op := grpcsec.StartHandlerOperation(grpcsec.HandlerOperationArgs{}, nil) + defer func() { + events := op.Finish(grpcsec.HandlerOperationRes{}) + if len(events) == 0 { + return + } + setAppSecTags(ctx, span, events) + }() + defer grpcsec.StartReceiveOperation(grpcsec.ReceiveOperationArgs{}, op).Finish(grpcsec.ReceiveOperationRes{Message: req}) + return handler(ctx, req) + } +} + +// StreamHandler wrapper to use when AppSec is enabled to monitor its execution. +func appsecStreamHandlerMiddleware(span ddtrace.Span, handler grpc.StreamHandler) grpc.StreamHandler { + httpsec.SetAppSecTags(span) + return func(srv interface{}, stream grpc.ServerStream) error { + op := grpcsec.StartHandlerOperation(grpcsec.HandlerOperationArgs{}, nil) + defer func() { + events := op.Finish(grpcsec.HandlerOperationRes{}) + if len(events) == 0 { + return + } + setAppSecTags(stream.Context(), span, events) + }() + return handler(srv, appsecServerStream{ServerStream: stream, handlerOperation: op}) + } +} + +type appsecServerStream struct { + grpc.ServerStream + handlerOperation *grpcsec.HandlerOperation +} + +// RecvMsg implements grpc.ServerStream interface method to monitor its +// execution with AppSec. +func (ss appsecServerStream) RecvMsg(m interface{}) error { + op := grpcsec.StartReceiveOperation(grpcsec.ReceiveOperationArgs{}, ss.handlerOperation) + defer func() { + op.Finish(grpcsec.ReceiveOperationRes{Message: m}) + }() + return ss.ServerStream.RecvMsg(m) +} + +// Set the AppSec tags when security events were found. +func setAppSecTags(ctx context.Context, span ddtrace.Span, events []json.RawMessage) { + md, _ := metadata.FromIncomingContext(ctx) + var addr net.Addr + if p, ok := peer.FromContext(ctx); ok { + addr = p.Addr + } + grpcsec.SetSecurityEventTags(span, events, addr, md) +} diff --git a/contrib/google.golang.org/grpc/appsec_test.go b/contrib/google.golang.org/grpc/appsec_test.go new file mode 100644 index 0000000000..f424380cb4 --- /dev/null +++ b/contrib/google.golang.org/grpc/appsec_test.go @@ -0,0 +1,91 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package grpc + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec" +) + +func TestAppSec(t *testing.T) { + appsec.Start() + defer appsec.Stop() + if !appsec.Enabled() { + t.Skip("appsec disabled") + } + + rig, err := newRig(false) + require.NoError(t, err) + defer rig.Close() + + client := rig.client + + t.Run("unary", func(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + + // Send a XSS attack + res, err := client.Ping(context.Background(), &FixtureRequest{Name: ""}) + // Check that the handler was properly called + require.NoError(t, err) + require.Equal(t, "passed", res.Message) + + finished := mt.FinishedSpans() + require.Len(t, finished, 1) + + // The request should have the XSS attack attempt event (appsec rule id crs-941-100). + event := finished[0].Tag("_dd.appsec.json") + require.NotNil(t, event) + require.True(t, strings.Contains(event.(string), "crs-941-100")) + }) + + t.Run("stream", func(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + + stream, err := client.StreamPing(context.Background()) + require.NoError(t, err) + + // Send a XSS attack + err = stream.Send(&FixtureRequest{Name: ""}) + require.NoError(t, err) + + // Check that the handler was properly called + res, err := stream.Recv() + require.Equal(t, "passed", res.Message) + require.NoError(t, err) + + // Send a SQLi attack + err = stream.Send(&FixtureRequest{Name: "something UNION SELECT * from users"}) + require.NoError(t, err) + + // Check that the handler was properly called + res, err = stream.Recv() + require.Equal(t, "passed", res.Message) + require.NoError(t, err) + + err = stream.CloseSend() + require.NoError(t, err) + // to flush the spans + stream.Recv() + + finished := mt.FinishedSpans() + require.Len(t, finished, 6) + + // The request should both attacks: the XSS and SQLi attack attempt + // events (appsec rule id crs-941-100, crs-942-100). + event := finished[5].Tag("_dd.appsec.json") + require.NotNil(t, event) + require.True(t, strings.Contains(event.(string), "crs-941-100")) + require.True(t, strings.Contains(event.(string), "crs-942-100")) + }) +} diff --git a/contrib/google.golang.org/grpc/server.go b/contrib/google.golang.org/grpc/server.go index 11320294bc..89806f2ff5 100644 --- a/contrib/google.golang.org/grpc/server.go +++ b/contrib/google.golang.org/grpc/server.go @@ -8,11 +8,12 @@ package grpc import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" "github.com/golang/protobuf/jsonpb" "github.com/golang/protobuf/proto" - context "golang.org/x/net/context" + "golang.org/x/net/context" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) @@ -101,18 +102,19 @@ func StreamServerInterceptor(opts ...Option) grpc.StreamServerInterceptor { span.SetTag(tagMethodKind, methodKindClientStream) } defer func() { finishWithError(span, err, cfg) }() + if appsec.Enabled() { + handler = appsecStreamHandlerMiddleware(span, handler) + } } // call the original handler with a new stream, which traces each send // and recv if message tracing is enabled - err = handler(srv, &serverStream{ + return handler(srv, &serverStream{ ServerStream: ss, cfg: cfg, method: info.FullMethod, ctx: ctx, }) - - return err } } @@ -154,6 +156,9 @@ func UnaryServerInterceptor(opts ...Option) grpc.UnaryServerInterceptor { } } } + if appsec.Enabled() { + handler = appsecUnaryHandlerMiddleware(span, handler) + } resp, err := handler(ctx, req) finishWithError(span, err, cfg) return resp, err diff --git a/contrib/google.golang.org/grpc/stats_server_test.go b/contrib/google.golang.org/grpc/stats_server_test.go index 389fd1b312..a35efb1c0e 100644 --- a/contrib/google.golang.org/grpc/stats_server_test.go +++ b/contrib/google.golang.org/grpc/stats_server_test.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/stretchr/testify/assert" - context "golang.org/x/net/context" + "golang.org/x/net/context" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/stats" diff --git a/contrib/net/http/http.go b/contrib/net/http/http.go index a98821b94a..7be8c5bf80 100644 --- a/contrib/net/http/http.go +++ b/contrib/net/http/http.go @@ -64,6 +64,10 @@ func WrapHandler(h http.Handler, service, resource string, opts ...Option) http. } log.Debug("contrib/net/http: Wrapping Handler: Service: %s, Resource: %s, %#v", service, resource, cfg) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if cfg.ignoreRequest(req) { + h.ServeHTTP(w, req) + return + } httputil.TraceAndServe(h, &httputil.TraceConfig{ ResponseWriter: w, Request: req, diff --git a/contrib/net/http/http_test.go b/contrib/net/http/http_test.go index 9cdf5b511b..4041da2276 100644 --- a/contrib/net/http/http_test.go +++ b/contrib/net/http/http_test.go @@ -216,14 +216,15 @@ func TestIgnoreRequestOption(t *testing.T) { spanCount: 1, }, } - mux := NewServeMux(WithIgnoreRequest(func(req *http.Request) bool { + ignore := func(req *http.Request) bool { return req.URL.Path == "/skip" - })) + } + mux := NewServeMux(WithIgnoreRequest(ignore)) mux.HandleFunc("/skip", handler200) mux.HandleFunc("/200", handler200) for _, test := range tests { - t.Run(test.url, func(t *testing.T) { + t.Run("servemux"+test.url, func(t *testing.T) { mt := mocktracer.Start() defer mt.Stop() r := httptest.NewRequest("GET", "http://localhost"+test.url, nil) @@ -233,6 +234,19 @@ func TestIgnoreRequestOption(t *testing.T) { spans := mt.FinishedSpans() assert.Equal(t, test.spanCount, len(spans)) }) + + t.Run("wraphandler"+test.url, func(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + r := httptest.NewRequest("GET", "http://localhost"+test.url, nil) + w := httptest.NewRecorder() + f := http.HandlerFunc(handler200) + handler := WrapHandler(f, "my-service", "my-resource", WithIgnoreRequest(ignore)) + handler.ServeHTTP(w, r) + + spans := mt.FinishedSpans() + assert.Equal(t, test.spanCount, len(spans)) + }) } } diff --git a/ddtrace/tracer/log.go b/ddtrace/tracer/log.go index b0ca8efe13..f609c4b774 100644 --- a/ddtrace/tracer/log.go +++ b/ddtrace/tracer/log.go @@ -17,13 +17,10 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec" "gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" + "gopkg.in/DataDog/dd-trace-go.v1/internal/osinfo" "gopkg.in/DataDog/dd-trace-go.v1/internal/version" ) -const ( - unknown = "unknown" -) - // startupInfo contains various information about the status of the tracer on startup. type startupInfo struct { Date string `json:"date"` // ISO 8601 date and time of start @@ -82,8 +79,8 @@ func logStartup(t *tracer) { info := startupInfo{ Date: time.Now().Format(time.RFC3339), - OSName: osName(), - OSVersion: osVersion(), + OSName: osinfo.OSName(), + OSVersion: osinfo.OSVersion(), Version: version.Tag, Lang: "Go", LangVersion: runtime.Version(), diff --git a/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go b/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go new file mode 100644 index 0000000000..41fe5d3838 --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go @@ -0,0 +1,177 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +// Package grpcsec is the gRPC instrumentation API and contract for AppSec +// defining an abstract run-time representation of gRPC handlers. +// gRPC integrations must use this package to enable AppSec features for gRPC, +// which listens to this package's operation events. +package grpcsec + +import ( + "encoding/json" + "reflect" + "sync" + + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo" +) + +// Abstract gRPC server handler operation definitions. It is based on two +// operations allowing to describe every type of RPC: the HandlerOperation type +// which represents the RPC handler, and the ReceiveOperation type which +// represents the messages the RPC handler receives during its lifetime. +// This means that the ReceiveOperation(s) will happen within the +// HandlerOperation. +// Every type of RPC, unary, client streaming, server streaming, and +// bidirectional streaming RPCs, can be all represented with a HandlerOperation +// having one or several ReceiveOperation. +// The send operation is not required for now and therefore not defined, which +// means that server and bidirectional streaming RPCs currently have the same +// run-time representation as unary and client streaming RPCs. +type ( + // HandlerOperation represents a gRPC server handler operation. + // It must be created with StartHandlerOperation() and finished with its + // Finish() method. + // Security events observed during the operation lifetime should be added + // to the operation using its AddSecurityEvent() method. + HandlerOperation struct { + dyngo.Operation + + events []json.RawMessage + mu sync.Mutex + } + // HandlerOperationArgs is the grpc handler arguments. Empty as of today. + HandlerOperationArgs struct{} + // HandlerOperationRes is the grpc handler results. Empty as of today. + HandlerOperationRes struct{} + + // ReceiveOperation type representing an gRPC server handler operation. It must + // be created with StartReceiveOperation() and finished with its Finish(). + ReceiveOperation struct { + dyngo.Operation + } + // ReceiveOperationArgs is the gRPC handler receive operation arguments + // Empty as of today. + ReceiveOperationArgs struct{} + // ReceiveOperationRes is the gRPC handler receive operation results which + // contains the message the gRPC handler received. + ReceiveOperationRes struct { + // Message received by the gRPC handler. + // Corresponds to the address `grpc.server.request.message`. + Message interface{} + } +) + +// TODO(Julio-Guerra): create a go-generate tool to generate the types, vars and methods below + +// StartHandlerOperation starts an gRPC server handler operation, along with the +// given arguments and parent operation, and emits a start event up in the +// operation stack. When parent is nil, the operation is linked to the global +// root operation. +func StartHandlerOperation(args HandlerOperationArgs, parent dyngo.Operation) *HandlerOperation { + op := &HandlerOperation{Operation: dyngo.NewOperation(parent)} + dyngo.StartOperation(op, args) + return op +} + +// Finish the gRPC handler operation, along with the given results, and emit a +// finish event up in the operation stack. +func (op *HandlerOperation) Finish(res HandlerOperationRes) []json.RawMessage { + dyngo.FinishOperation(op, res) + return op.events +} + +// AddSecurityEvent adds the security event to the list of events observed +// during the operation lifetime. +func (op *HandlerOperation) AddSecurityEvent(event json.RawMessage) { + op.mu.Lock() + defer op.mu.Unlock() + op.events = append(op.events, event) +} + +// gRPC handler operation's start and finish event callback function types. +type ( + // OnHandlerOperationStart function type, called when an gRPC handler + // operation starts. + OnHandlerOperationStart func(*HandlerOperation, HandlerOperationArgs) + // OnHandlerOperationFinish function type, called when an gRPC handler + // operation finishes. + OnHandlerOperationFinish func(*HandlerOperation, HandlerOperationRes) +) + +var ( + handlerOperationArgsType = reflect.TypeOf((*HandlerOperationArgs)(nil)).Elem() + handlerOperationResType = reflect.TypeOf((*HandlerOperationRes)(nil)).Elem() +) + +// ListenedType returns the type a OnHandlerOperationStart event listener +// listens to, which is the HandlerOperationArgs type. +func (OnHandlerOperationStart) ListenedType() reflect.Type { return handlerOperationArgsType } + +// Call the underlying event listener function by performing the type-assertion +// on v whose type is the one returned by ListenedType(). +func (f OnHandlerOperationStart) Call(op dyngo.Operation, v interface{}) { + f(op.(*HandlerOperation), v.(HandlerOperationArgs)) +} + +// ListenedType returns the type a OnHandlerOperationFinish event listener +// listens to, which is the HandlerOperationRes type. +func (OnHandlerOperationFinish) ListenedType() reflect.Type { return handlerOperationResType } + +// Call the underlying event listener function by performing the type-assertion +// on v whose type is the one returned by ListenedType(). +func (f OnHandlerOperationFinish) Call(op dyngo.Operation, v interface{}) { + f(op.(*HandlerOperation), v.(HandlerOperationRes)) +} + +// StartReceiveOperation starts a receive operation of a gRPC handler, along +// with the given arguments and parent operation, and emits a start event up in +// the operation stack. When parent is nil, the operation is linked to the +// global root operation. +func StartReceiveOperation(args ReceiveOperationArgs, parent dyngo.Operation) ReceiveOperation { + op := ReceiveOperation{Operation: dyngo.NewOperation(parent)} + dyngo.StartOperation(op, args) + return op +} + +// Finish the gRPC handler operation, along with the given results, and emits a +// finish event up in the operation stack. +func (op ReceiveOperation) Finish(res ReceiveOperationRes) { + dyngo.FinishOperation(op, res) +} + +// gRPC receive operation's start and finish event callback function types. +type ( + // OnReceiveOperationStart function type, called when a gRPC receive + // operation starts. + OnReceiveOperationStart func(ReceiveOperation, ReceiveOperationArgs) + // OnReceiveOperationFinish function type, called when a grpc receive + // operation finishes. + OnReceiveOperationFinish func(ReceiveOperation, ReceiveOperationRes) +) + +var ( + receiveOperationArgsType = reflect.TypeOf((*ReceiveOperationArgs)(nil)).Elem() + receiveOperationResType = reflect.TypeOf((*ReceiveOperationRes)(nil)).Elem() +) + +// ListenedType returns the type a OnHandlerOperationStart event listener +// listens to, which is the HandlerOperationArgs type. +func (OnReceiveOperationStart) ListenedType() reflect.Type { return receiveOperationArgsType } + +// Call the underlying event listener function by performing the type-assertion +// on v whose type is the one returned by ListenedType(). +func (f OnReceiveOperationStart) Call(op dyngo.Operation, v interface{}) { + f(op.(ReceiveOperation), v.(ReceiveOperationArgs)) +} + +// ListenedType returns the type a OnHandlerOperationFinish event listener +// listens to, which is the HandlerOperationRes type. +func (OnReceiveOperationFinish) ListenedType() reflect.Type { return receiveOperationResType } + +// Call the underlying event listener function by performing the type-assertion +// on v whose type is the one returned by ListenedType(). +func (f OnReceiveOperationFinish) Call(op dyngo.Operation, v interface{}) { + f(op.(ReceiveOperation), v.(ReceiveOperationRes)) +} diff --git a/internal/appsec/dyngo/instrumentation/grpcsec/grpc_test.go b/internal/appsec/dyngo/instrumentation/grpcsec/grpc_test.go new file mode 100644 index 0000000000..cc7e637c1b --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/grpcsec/grpc_test.go @@ -0,0 +1,85 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package grpcsec_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo" + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo/instrumentation/grpcsec" +) + +func TestUsage(t *testing.T) { + testRPCRepresentation := func(expectedRecvOperation int) func(*testing.T) { + return func(t *testing.T) { + type ( + rootArgs struct{} + rootRes struct{} + ) + localRootOp := dyngo.NewOperation(nil) + dyngo.StartOperation(localRootOp, rootArgs{}) + defer dyngo.FinishOperation(localRootOp, rootRes{}) + + var handlerStarted, handlerFinished, recvStarted, recvFinished int + defer func() { + require.Equal(t, 1, handlerStarted) + require.Equal(t, 1, handlerFinished) + require.Equal(t, expectedRecvOperation, recvStarted) + require.Equal(t, expectedRecvOperation, recvFinished) + }() + + const expectedMessageFormat = "message number %d" + + localRootOp.On(grpcsec.OnHandlerOperationStart(func(handlerOp *grpcsec.HandlerOperation, args grpcsec.HandlerOperationArgs) { + handlerStarted++ + + handlerOp.On(grpcsec.OnReceiveOperationStart(func(op grpcsec.ReceiveOperation, _ grpcsec.ReceiveOperationArgs) { + recvStarted++ + + op.On(grpcsec.OnReceiveOperationFinish(func(_ grpcsec.ReceiveOperation, res grpcsec.ReceiveOperationRes) { + expectedMessage := fmt.Sprintf(expectedMessageFormat, recvStarted) + require.Equal(t, expectedMessage, res.Message) + recvFinished++ + + handlerOp.AddSecurityEvent(json.RawMessage(expectedMessage)) + })) + })) + + handlerOp.On(grpcsec.OnHandlerOperationFinish(func(*grpcsec.HandlerOperation, grpcsec.HandlerOperationRes) { + handlerFinished++ + })) + })) + + rpcOp := grpcsec.StartHandlerOperation(grpcsec.HandlerOperationArgs{}, localRootOp) + + for i := 1; i <= expectedRecvOperation; i++ { + recvOp := grpcsec.StartReceiveOperation(grpcsec.ReceiveOperationArgs{}, rpcOp) + recvOp.Finish(grpcsec.ReceiveOperationRes{Message: fmt.Sprintf(expectedMessageFormat, i)}) + } + + secEvents := rpcOp.Finish(grpcsec.HandlerOperationRes{}) + + require.Len(t, secEvents, expectedRecvOperation) + for i, e := range secEvents { + require.Equal(t, fmt.Sprintf(expectedMessageFormat, i+1), string(e)) + } + } + } + + // Unary RPCs are represented by a single receive operation + t.Run("unary-representation", testRPCRepresentation(1)) + // Client streaming RPCs are represented by many receive operations. + t.Run("client-streaming-representation", testRPCRepresentation(10)) + // Server and bidirectional streaming RPCs cannot be tested for now because + // the send operations are not used nor defined yet, server streaming RPCs + // are currently represented like unary RPCs (1 client message, N server + // messages), and bidirectional RPCs like client streaming RPCs (N client + // messages, M server messages). +} diff --git a/internal/appsec/dyngo/instrumentation/grpcsec/tags.go b/internal/appsec/dyngo/instrumentation/grpcsec/tags.go new file mode 100644 index 0000000000..9f36ce2fd1 --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/grpcsec/tags.go @@ -0,0 +1,102 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package grpcsec + +import ( + "encoding/json" + "fmt" + "net" + + "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/appsec/dyngo/instrumentation/httpsec" + "gopkg.in/DataDog/dd-trace-go.v1/internal/log" +) + +// SetSecurityEventTags sets the AppSec-specific span tags when a security event +// occurred into the service entry span. +func SetSecurityEventTags(span ddtrace.Span, events []json.RawMessage, addr net.Addr, md map[string][]string) { + if err := setSecurityEventTags(span, events, addr, md); err != nil { + log.Error("appsec: %v", err) + } +} + +func setSecurityEventTags(span ddtrace.Span, events []json.RawMessage, addr net.Addr, md map[string][]string) error { + if err := setEventSpanTags(span, events); err != nil { + return err + } + var ip string + switch actual := addr.(type) { + case *net.UDPAddr: + ip = actual.IP.String() + case *net.TCPAddr: + ip = actual.IP.String() + } + if ip != "" { + span.SetTag("network.client.ip", ip) + } + for h, v := range httpsec.NormalizeHTTPHeaders(md) { + span.SetTag("grpc.metadata."+h, v) + } + return nil +} + +// setEventSpanTags sets the security event span tags into the service entry span. +func setEventSpanTags(span ddtrace.Span, events []json.RawMessage) error { + // Set the appsec event span tag + val, err := makeEventTagValue(events) + if err != nil { + return err + } + span.SetTag("_dd.appsec.json", string(val)) + // Keep this span due to the security event + span.SetTag(ext.ManualKeep, true) + span.SetTag("_dd.origin", "appsec") + // Set the appsec.event tag needed by the appsec backend + span.SetTag("appsec.event", true) + return nil +} + +// Create the value of the security event tag. +// TODO(Julio-Guerra): a future libddwaf version should return something +// avoiding us the following events concatenation logic which currently +// involves unserializing the top-level JSON arrays to concatenate them +// together. +// TODO(Julio-Guerra): avoid serializing the json in the request hot path +func makeEventTagValue(events []json.RawMessage) (json.RawMessage, error) { + var v interface{} + if l := len(events); l == 1 { + // eventTag is the structure to use in the `_dd.appsec.json` span tag. + // In this case of 1 event, it already is an array as expected. + type eventTag struct { + Triggers json.RawMessage `json:"triggers"` + } + v = eventTag{Triggers: events[0]} + } else { + // eventTag is the structure to use in the `_dd.appsec.json` span tag. + // With more than one event, we need to concatenate the arrays together + // (ie. convert [][]json.RawMessage into []json.RawMessage). + type eventTag struct { + Triggers []json.RawMessage `json:"triggers"` + } + concatenated := make([]json.RawMessage, 0, l) // at least len(events) + for _, event := range events { + // Unmarshal the top level array + var tmp []json.RawMessage + if err := json.Unmarshal(event, &tmp); err != nil { + return nil, fmt.Errorf("unexpected error while unserializing the appsec event `%s`: %v", string(event), err) + } + concatenated = append(concatenated, tmp...) + } + v = eventTag{Triggers: concatenated} + } + + tag, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("unexpected error while serializing the appsec event span tag: %v", err) + } + return tag, nil +} diff --git a/internal/appsec/dyngo/instrumentation/grpcsec/tags_test.go b/internal/appsec/dyngo/instrumentation/grpcsec/tags_test.go new file mode 100644 index 0000000000..ce6e79144e --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/grpcsec/tags_test.go @@ -0,0 +1,182 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package grpcsec + +import ( + "encoding/json" + "fmt" + "net" + "testing" + + "github.com/stretchr/testify/require" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" +) + +func TestSetSecurityEventTags(t *testing.T) { + for _, eventCase := range []struct { + name string + events []json.RawMessage + expectedTag string + expectedError bool + }{ + { + name: "one-event", + events: []json.RawMessage{json.RawMessage(`["one","two"]`)}, + expectedTag: `{"triggers":["one","two"]}`, + }, + { + name: "one-event-with-json-error", + events: []json.RawMessage{json.RawMessage(`["one",two"]`)}, + expectedError: true, + }, + { + name: "two-events", + events: []json.RawMessage{json.RawMessage(`["one","two"]`), json.RawMessage(`["three","four"]`)}, + expectedTag: `{"triggers":["one","two","three","four"]}`, + }, + { + name: "two-events-with-json-error", + events: []json.RawMessage{json.RawMessage(`["one","two"]`), json.RawMessage(`["three,"four"]`)}, + expectedError: true, + }, + { + name: "three-events-with-json-error", + events: []json.RawMessage{json.RawMessage(`["one","two"]`), json.RawMessage(`["three","four"]`), json.RawMessage(`"five"`)}, + expectedError: true, + }, + } { + eventCase := eventCase + for _, addrCase := range []struct { + name string + addr net.Addr + expectedTag string + }{ + { + name: "tcp-ipv4-address", + addr: &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 6789}, + expectedTag: "1.2.3.4", + }, + { + name: "tcp-ipv6-address", + addr: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 6789}, + expectedTag: "::1", + }, + { + name: "udp-ipv4-address", + addr: &net.UDPAddr{IP: net.ParseIP("1.2.3.4"), Port: 6789}, + expectedTag: "1.2.3.4", + }, + { + name: "udp-ipv6-address", + addr: &net.UDPAddr{IP: net.ParseIP("::1"), Port: 6789}, + expectedTag: "::1", + }, + { + name: "unix-socket-address", + addr: &net.UnixAddr{Name: "/var/my.sock"}, + }, + } { + addrCase := addrCase + for _, metadataCase := range []struct { + name string + md map[string][]string + expectedTags map[string]string + }{ + { + name: "zero-metadata", + }, + { + name: "xff-metadata", + md: map[string][]string{ + "x-forwarded-for": {"1.2.3.4", "4.5.6.7"}, + ":authority": {"something"}, + }, + expectedTags: map[string]string{ + "grpc.metadata.x-forwarded-for": "1.2.3.4,4.5.6.7", + }, + }, + { + name: "xff-metadata", + md: map[string][]string{ + "x-forwarded-for": {"1.2.3.4"}, + ":authority": {"something"}, + }, + expectedTags: map[string]string{ + "grpc.metadata.x-forwarded-for": "1.2.3.4", + }, + }, + { + name: "no-monitored-metadata", + md: map[string][]string{ + ":authority": {"something"}, + }, + }, + } { + metadataCase := metadataCase + t.Run(fmt.Sprintf("%s-%s-%s", eventCase.name, addrCase.name, metadataCase.name), func(t *testing.T) { + var span MockSpan + err := setSecurityEventTags(&span, eventCase.events, addrCase.addr, metadataCase.md) + if eventCase.expectedError { + require.Error(t, err) + return + } + require.NoError(t, err) + + expectedTags := map[string]interface{}{ + "_dd.appsec.json": eventCase.expectedTag, + "manual.keep": true, + "appsec.event": true, + "_dd.origin": "appsec", + } + + if addr := addrCase.expectedTag; addr != "" { + expectedTags["network.client.ip"] = addr + } + + for k, v := range metadataCase.expectedTags { + expectedTags[k] = v + } + + require.Equal(t, expectedTags, span.tags) + require.False(t, span.finished) + }) + } + } + } +} + +type MockSpan struct { + tags map[string]interface{} + finished bool +} + +func (m *MockSpan) SetTag(key string, value interface{}) { + if m.tags == nil { + m.tags = make(map[string]interface{}) + } + m.tags[key] = value +} + +func (m *MockSpan) SetOperationName(operationName string) { + panic("unused") +} + +func (m *MockSpan) BaggageItem(key string) string { + panic("unused") +} + +func (m *MockSpan) SetBaggageItem(key, val string) { + panic("unused") +} + +func (m *MockSpan) Finish(opts ...ddtrace.FinishOption) { + m.finished = true +} + +func (m *MockSpan) Context() ddtrace.SpanContext { + panic("unused") +} diff --git a/internal/appsec/dyngo/instrumentation/httpsec/tags.go b/internal/appsec/dyngo/instrumentation/httpsec/tags.go index 50de3a435a..77ae050586 100644 --- a/internal/appsec/dyngo/instrumentation/httpsec/tags.go +++ b/internal/appsec/dyngo/instrumentation/httpsec/tags.go @@ -47,10 +47,10 @@ func setEventSpanTags(span ddtrace.Span, events json.RawMessage) { func SetSecurityEventTags(span ddtrace.Span, events json.RawMessage, remoteIP string, headers, respHeaders map[string][]string) { setEventSpanTags(span, events) span.SetTag("network.client.ip", remoteIP) - for h, v := range normalizeHTTPHeaders(headers) { + for h, v := range NormalizeHTTPHeaders(headers) { span.SetTag("http.request.headers."+h, v) } - for h, v := range normalizeHTTPHeaders(respHeaders) { + for h, v := range NormalizeHTTPHeaders(respHeaders) { span.SetTag("http.response.headers."+h, v) } } @@ -83,8 +83,9 @@ func init() { sort.Strings(collectedHTTPHeaders[:]) } -// normalizeHTTPHeaders returns the HTTP headers following Datadog's normalization format. -func normalizeHTTPHeaders(headers map[string][]string) (normalized map[string]string) { +// NormalizeHTTPHeaders returns the HTTP headers following Datadog's +// normalization format. +func NormalizeHTTPHeaders(headers map[string][]string) (normalized map[string]string) { if len(headers) == 0 { return nil } diff --git a/internal/appsec/dyngo/instrumentation/httpsec/tags_test.go b/internal/appsec/dyngo/instrumentation/httpsec/tags_test.go index 591a433ab1..fc07db4c12 100644 --- a/internal/appsec/dyngo/instrumentation/httpsec/tags_test.go +++ b/internal/appsec/dyngo/instrumentation/httpsec/tags_test.go @@ -45,7 +45,7 @@ func TestNormalizeHTTPHeaders(t *testing.T) { }, }, } { - headers := normalizeHTTPHeaders(tc.headers) + headers := NormalizeHTTPHeaders(tc.headers) require.Equal(t, tc.expected, headers) } } diff --git a/internal/appsec/waf.go b/internal/appsec/waf.go index 7bb8ef4280..4fd45d9329 100644 --- a/internal/appsec/waf.go +++ b/internal/appsec/waf.go @@ -12,9 +12,12 @@ import ( "errors" "fmt" "sort" + "sync" + "sync/atomic" "time" "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo" + "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo/instrumentation/grpcsec" "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo/instrumentation/httpsec" "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/waf" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" @@ -48,24 +51,38 @@ func registerWAF(rules []byte, timeout time.Duration) (unreg dyngo.UnregisterFun return nil, errors.New("no addresses found in the rule") } // Check there are supported addresses in the rule - addresses, notSupported := supportedAddresses(ruleAddresses) - if len(addresses) == 0 { + httpAddresses, grpcAddresses, notSupported := supportedAddresses(ruleAddresses) + if len(httpAddresses) == 0 && len(grpcAddresses) == 0 { return nil, fmt.Errorf("the addresses present in the rule are not supported: %v", notSupported) } else if len(notSupported) > 0 { - log.Debug("appsec: the addresses present in the rule are partially supported: supported=%v, not supported=%v", addresses, notSupported) + log.Debug("appsec: the addresses present in the rule are partially supported: not supported=%v", notSupported) } // Register the WAF event listener - unregister := dyngo.Register(newWAFEventListener(waf, addresses, timeout)) + var unregisterHTTP, unregisterGRPC dyngo.UnregisterFunc + if len(httpAddresses) > 0 { + log.Debug("appsec: registering http waf listening to addresses %v", httpAddresses) + unregisterHTTP = dyngo.Register(newHTTPWAFEventListener(waf, httpAddresses, timeout)) + } + if len(grpcAddresses) > 0 { + log.Debug("appsec: registering grpc waf listening to addresses %v", grpcAddresses) + unregisterGRPC = dyngo.Register(newGRPCWAFEventListener(waf, grpcAddresses, timeout)) + } + // Return an unregistration function that will also release the WAF instance. return func() { defer waf.Close() - unregister() + if unregisterHTTP != nil { + unregisterHTTP() + } + if unregisterGRPC != nil { + unregisterGRPC() + } }, nil } // newWAFEventListener returns the WAF event listener to register in order to enable it. -func newWAFEventListener(handle *waf.Handle, addresses []string, timeout time.Duration) dyngo.EventListener { +func newHTTPWAFEventListener(handle *waf.Handle, addresses []string, timeout time.Duration) dyngo.EventListener { return httpsec.OnHandlerOperationStart(func(op *httpsec.Operation, args httpsec.HandlerOperationArgs) { // At the moment, AppSec doesn't block the requests, and so we can use the fact we are in monitoring-only mode // to call the WAF only once at the end of the handler operation. @@ -106,6 +123,53 @@ func newWAFEventListener(handle *waf.Handle, addresses []string, timeout time.Du }) } +// newGRPCWAFEventListener returns the WAF event listener to register in order +// to enable it. +func newGRPCWAFEventListener(handle *waf.Handle, addresses []string, timeout time.Duration) dyngo.EventListener { + return grpcsec.OnHandlerOperationStart(func(op *grpcsec.HandlerOperation, _ grpcsec.HandlerOperationArgs) { + // Limit the maximum number of security events, as a streaming RPC could + // receive unlimited number of messages where we could find security events + const maxWAFEventsPerRequest = 10 + var ( + nbEvents uint32 + logOnce sync.Once + ) + op.On(grpcsec.OnReceiveOperationFinish(func(_ grpcsec.ReceiveOperation, res grpcsec.ReceiveOperationRes) { + if atomic.LoadUint32(&nbEvents) == maxWAFEventsPerRequest { + logOnce.Do(func() { + log.Debug("appsec: ignoring the rpc message due to the maximum number of security events per grpc call reached") + }) + return + } + // The current workaround of the WAF context limitations is to + // simply instantiate and release the WAF context for the operation + // lifetime so that: + // 1. We avoid growing the memory usage of the context every time + // a grpc.server.request.message value is added to it during + // the RPC lifetime. + // 2. We avoid the limitation of 1 event per attack type. + // TODO(Julio-Guerra): a future libddwaf API should solve this out. + wafCtx := waf.NewContext(handle) + if wafCtx == nil { + // The WAF event listener got concurrently released + return + } + defer wafCtx.Close() + // Run the WAF on the rule addresses available in the args + // Note that we don't check if the address is present in the rules + // as we only support one at the moment, so this callback cannot be + // set when the address is not present. + events := runWAF(wafCtx, map[string]interface{}{grpcServerRequestMessage: res.Message}, timeout) + if len(events) == 0 { + return + } + log.Debug("appsec: attack detected by the grpc waf") + atomic.AddUint32(&nbEvents, 1) + op.AddSecurityEvent(events) + })) + }) +} + func runWAF(wafCtx *waf.Context, values map[string]interface{}, timeout time.Duration) []byte { matches, err := wafCtx.Run(values, timeout) if err != nil { @@ -119,7 +183,7 @@ func runWAF(wafCtx *waf.Context, values map[string]interface{}, timeout time.Dur return matches } -// Rule addresses currently supported by the WAF +// HTTP rule addresses currently supported by the WAF const ( serverRequestRawURIAddr = "server.request.uri.raw" serverRequestHeadersNoCookiesAddr = "server.request.headers.no_cookies" @@ -128,8 +192,8 @@ const ( serverResponseStatusAddr = "server.response.status" ) -// List of rule addresses currently supported by the WAF -var supportedAddressesList = []string{ +// List of HTTP rule addresses currently supported by the WAF +var httpAddresses = []string{ serverRequestRawURIAddr, serverRequestHeadersNoCookiesAddr, serverRequestCookiesAddr, @@ -137,23 +201,34 @@ var supportedAddressesList = []string{ serverResponseStatusAddr, } +// gRPC rule addresses currently supported by the WAF +const ( + grpcServerRequestMessage = "grpc.server.request.message" +) + +// List of gRPC rule addresses currently supported by the WAF +var grpcAddresses = []string{ + grpcServerRequestMessage, +} + func init() { - sort.Strings(supportedAddressesList) + // sort the address lists to avoid mistakes and use sort.SearchStrings() + sort.Strings(httpAddresses) + sort.Strings(grpcAddresses) } // supportedAddresses returns the list of addresses we actually support from the // given rule addresses. -func supportedAddresses(ruleAddresses []string) (supported, notSupported []string) { +func supportedAddresses(ruleAddresses []string) (supportedHTTP, supportedGRPC, notSupported []string) { // Filter the supported addresses only - l := len(supportedAddressesList) - supported = make([]string, 0, l) for _, addr := range ruleAddresses { - if i := sort.SearchStrings(supportedAddressesList, addr); i < l && supportedAddressesList[i] == addr { - supported = append(supported, addr) + if i := sort.SearchStrings(httpAddresses, addr); i < len(httpAddresses) && httpAddresses[i] == addr { + supportedHTTP = append(supportedHTTP, addr) + } else if i := sort.SearchStrings(grpcAddresses, addr); i < len(grpcAddresses) && grpcAddresses[i] == addr { + supportedGRPC = append(supportedGRPC, addr) } else { notSupported = append(notSupported, addr) } } - // Check the resulting situation we are in - return supported, notSupported + return } diff --git a/internal/osinfo/osinfo.go b/internal/osinfo/osinfo.go new file mode 100644 index 0000000000..7519a917e9 --- /dev/null +++ b/internal/osinfo/osinfo.go @@ -0,0 +1,21 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022 Datadog, Inc. + +// Package osinfo provides information about the current operating system release +package osinfo + +// OSName returns the name of the operating system, including the distribution +// for Linux when possible. +func OSName() string { + // call out to OS-specific implementation + return osName() +} + +// OSVersion returns the operating system release, e.g. major/minor version +// number and build ID. +func OSVersion() string { + // call out to OS-specific implementation + return osVersion() +} diff --git a/ddtrace/tracer/osinfo_darwin.go b/internal/osinfo/osinfo_darwin.go similarity index 93% rename from ddtrace/tracer/osinfo_darwin.go rename to internal/osinfo/osinfo_darwin.go index b8a68791ea..32ead5fe05 100644 --- a/ddtrace/tracer/osinfo_darwin.go +++ b/internal/osinfo/osinfo_darwin.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2016 Datadog, Inc. -package tracer +package osinfo import ( "os/exec" @@ -18,7 +18,7 @@ func osName() string { func osVersion() string { out, err := exec.Command("sw_vers", "-productVersion").Output() if err != nil { - return unknown + return "unknown" } return strings.Trim(string(out), "\n") } diff --git a/ddtrace/tracer/osinfo_default.go b/internal/osinfo/osinfo_default.go similarity index 81% rename from ddtrace/tracer/osinfo_default.go rename to internal/osinfo/osinfo_default.go index c2937ebfb9..72d70d3885 100644 --- a/ddtrace/tracer/osinfo_default.go +++ b/internal/osinfo/osinfo_default.go @@ -3,9 +3,10 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2016 Datadog, Inc. +//go:build !windows && !linux && !darwin && !freebsd // +build !windows,!linux,!darwin,!freebsd -package tracer +package osinfo import ( "runtime" @@ -16,5 +17,5 @@ func osName() string { } func osVersion() string { - return unknown + return "unknown" } diff --git a/ddtrace/tracer/osinfo_freebsd.go b/internal/osinfo/osinfo_freebsd.go similarity index 93% rename from ddtrace/tracer/osinfo_freebsd.go rename to internal/osinfo/osinfo_freebsd.go index 03433890c2..543f2ffdfd 100644 --- a/ddtrace/tracer/osinfo_freebsd.go +++ b/internal/osinfo/osinfo_freebsd.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2016 Datadog, Inc. -package tracer +package osinfo import ( "os/exec" @@ -18,7 +18,7 @@ func osName() string { func osVersion() string { out, err := exec.Command("uname", "-r").Output() if err != nil { - return unknown + return "unknown" } return strings.Split(string(out), "-")[0] } diff --git a/ddtrace/tracer/osinfo_linux.go b/internal/osinfo/osinfo_linux.go similarity index 94% rename from ddtrace/tracer/osinfo_linux.go rename to internal/osinfo/osinfo_linux.go index 85c09ab818..6196d4d12d 100644 --- a/ddtrace/tracer/osinfo_linux.go +++ b/internal/osinfo/osinfo_linux.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2016 Datadog, Inc. -package tracer +package osinfo import ( "bufio" @@ -32,11 +32,11 @@ func osName() string { func osVersion() string { f, err := os.Open("/etc/os-release") if err != nil { - return unknown + return "unknown" } defer f.Close() s := bufio.NewScanner(f) - version := unknown + version := "unknown" for s.Scan() { parts := strings.SplitN(s.Text(), "=", 2) switch parts[0] { diff --git a/ddtrace/tracer/osinfo_windows.go b/internal/osinfo/osinfo_windows.go similarity index 94% rename from ddtrace/tracer/osinfo_windows.go rename to internal/osinfo/osinfo_windows.go index bffbd144ef..659bd9ce6a 100644 --- a/ddtrace/tracer/osinfo_windows.go +++ b/internal/osinfo/osinfo_windows.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2016 Datadog, Inc. -package tracer +package osinfo import ( "fmt" @@ -20,7 +20,7 @@ func osName() string { func osVersion() string { k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) if err != nil { - return unknown + return "unknown" } defer k.Close() @@ -34,7 +34,7 @@ func osVersion() string { version.WriteString(fmt.Sprintf(".%d", min)) } } else { - version.WriteString(unknown) + version.WriteString("unknown") } ed, _, err := k.GetStringValue("EditionID") diff --git a/profiler/options.go b/profiler/options.go index 24f773dc61..0fed66e0ba 100644 --- a/profiler/options.go +++ b/profiler/options.go @@ -7,6 +7,7 @@ package profiler import ( "context" + "encoding/json" "fmt" "net" "net/http" @@ -22,6 +23,7 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/internal" "gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" + "gopkg.in/DataDog/dd-trace-go.v1/internal/osinfo" "gopkg.in/DataDog/dd-trace-go.v1/internal/version" "github.com/DataDog/datadog-go/v5/statsd" @@ -100,6 +102,62 @@ type config struct { blockRate int outputDir string deltaProfiles bool + logStartup bool +} + +// logStartup records the configuration to the configured logger in JSON format +func logStartup(c *config) { + info := struct { + Date string `json:"date"` // ISO 8601 date and time of start + OSName string `json:"os_name"` // Windows, Darwin, Debian, etc. + OSVersion string `json:"os_version"` // Version of the OS + Version string `json:"version"` // Profiler version + Lang string `json:"lang"` // "Go" + LangVersion string `json:"lang_version"` // Go version, e.g. go1.13 + Hostname string `json:"hostname"` + DeltaProfiles bool `json:"delta_profiles"` + Service string `json:"service"` + Env string `json:"env"` + TargetURL string `json:"target_url"` + Agentless bool `json:"agentless"` + Tags []string `json:"tags"` + ProfilePeriod string `json:"profile_period"` + EnabledProfiles []string `json:"enabled_profiles"` + CPUDuration string `json:"cpu_duration"` + BlockProfileRate int `json:"block_profile_rate"` + MutexProfileFraction int `json:"mutex_profile_fraction"` + MaxGoroutinesWait int `json:"max_goroutines_wait"` + UploadTimeout string `json:"upload_timeout"` + }{ + Date: time.Now().Format(time.RFC3339), + OSName: osinfo.OSName(), + OSVersion: osinfo.OSVersion(), + Version: version.Tag, + Lang: "Go", + LangVersion: runtime.Version(), + Hostname: c.hostname, + DeltaProfiles: c.deltaProfiles, + Service: c.service, + Env: c.env, + TargetURL: c.targetURL, + Agentless: c.agentless, + Tags: c.tags, + ProfilePeriod: c.period.String(), + CPUDuration: c.cpuDuration.String(), + BlockProfileRate: c.blockRate, + MutexProfileFraction: c.mutexFraction, + MaxGoroutinesWait: c.maxGoroutinesWait, + UploadTimeout: c.uploadTimeout.String(), + } + for t := range c.types { + info.EnabledProfiles = append(info.EnabledProfiles, t.String()) + } + b, err := json.Marshal(info) + if err != nil { + log.Error("Marshaling profiler configuration: %s", err) + return + } + log.Info("Profiler configuration: %s\n", b) } func urlForSite(site string) (string, error) { @@ -143,6 +201,7 @@ func defaultConfig() (*config, error) { maxGoroutinesWait: 1000, // arbitrary value, should limit STW to ~30ms tags: []string{fmt.Sprintf("pid:%d", os.Getpid())}, deltaProfiles: internal.BoolEnv("DD_PROFILING_DELTA", true), + logStartup: true, } for _, t := range defaultProfileTypes { c.addProfileType(t) @@ -411,3 +470,12 @@ func withOutputDir(dir string) Option { cfg.outputDir = dir } } + +// WithLogStartup toggles logging the configuration of the profiler to standard +// error when profiling is started. The configuration is logged in a JSON +// format. This option is enabled by default. +func WithLogStartup(enabled bool) Option { + return func(cfg *config) { + cfg.logStartup = enabled + } +} diff --git a/profiler/profiler.go b/profiler/profiler.go index cae7d0161a..ee9088ff35 100644 --- a/profiler/profiler.go +++ b/profiler/profiler.go @@ -131,6 +131,9 @@ func newProfiler(opts ...Option) (*profiler, error) { return nil, fmt.Errorf("unknown profile type: %d", pt) } } + if cfg.logStartup { + logStartup(cfg) + } p := profiler{ cfg: cfg, diff --git a/profiler/profiler_test.go b/profiler/profiler_test.go index 80232a82ab..1974784e27 100644 --- a/profiler/profiler_test.go +++ b/profiler/profiler_test.go @@ -10,6 +10,7 @@ import ( "net" "os" "runtime" + "strings" "sync" "sync/atomic" "testing" @@ -21,13 +22,31 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + // Profiling configuration is logged by default when starting a profile, + // so we want to discard it during tests to avoid flooding the terminal + // with logs + log.UseLogger(log.DiscardLogger{}) + os.Exit(m.Run()) +} + func TestStart(t *testing.T) { t.Run("defaults", func(t *testing.T) { + rl := &log.RecordLogger{} + defer log.UseLogger(rl)() + if err := Start(); err != nil { t.Fatal(err) } defer Stop() + // Profiler configuration should be logged by default. Check + // that we log some default configuration, e.g. enabled profiles + assert.LessOrEqual(t, 1, len(rl.Logs())) + startupLog := strings.Join(rl.Logs(), " ") + assert.Contains(t, startupLog, "cpu") + assert.Contains(t, startupLog, "heap") + mu.Lock() require.NotNil(t, activeProfiler) assert := assert.New(t) @@ -68,8 +87,10 @@ func TestStart(t *testing.T) { defer Stop() assert.Nil(t, err) assert.Equal(t, activeProfiler.cfg.agentURL, activeProfiler.cfg.targetURL) - assert.Equal(t, 1, len(rl.Logs())) - assert.Contains(t, rl.Logs()[0], "profiler.WithAPIKey") + // The package should log a warning that using an API has no + // effect unless uploading directly to Datadog (i.e. agentless) + assert.LessOrEqual(t, 1, len(rl.Logs())) + assert.Contains(t, strings.Join(rl.Logs(), " "), "profiler.WithAPIKey") }) t.Run("options/GoodAPIKey/Agentless", func(t *testing.T) { @@ -83,8 +104,10 @@ func TestStart(t *testing.T) { defer Stop() assert.Nil(t, err) assert.Equal(t, activeProfiler.cfg.apiURL, activeProfiler.cfg.targetURL) - assert.Equal(t, 1, len(rl.Logs())) - assert.Contains(t, rl.Logs()[0], "profiler.WithAgentlessUpload") + // The package should log a warning that agentless upload is not + // officially supported, so prefer not to use it + assert.LessOrEqual(t, 1, len(rl.Logs())) + assert.Contains(t, strings.Join(rl.Logs(), " "), "profiler.WithAgentlessUpload") }) t.Run("options/BadAPIKey", func(t *testing.T) { @@ -122,7 +145,8 @@ func TestStartStopIdempotency(t *testing.T) { go func() { defer wg.Done() for j := 0; j < 1000; j++ { - Start() + // startup logging makes this test very slow + Start(WithLogStartup(false)) } }() }