From 9a17a03be86866b3c67fbf5ec29b1139a7ede279 Mon Sep 17 00:00:00 2001 From: Juho Majasaari Date: Wed, 5 Jun 2024 14:25:12 +0300 Subject: [PATCH] Support matching JSON body with CEL expressions Signed-off-by: Juho Majasaari --- CONFIGURATION.md | 6 ++ config/config.go | 72 +++++++++++++++++++++++ go.mod | 9 ++- go.sum | 11 ++++ prober/http.go | 69 ++++++++++++++++++++++ prober/http_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 280991c9..9eefddd8 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -89,6 +89,12 @@ modules: # Probe fails if SSL is not present. [ fail_if_not_ssl: | default = false ] + # Probe fails if response body JSON matches CEL: + fail_if_body_matches_cel: + + # Probe fails if response body JSON does not match CEL: + fail_if_body_not_matches_cel: + # Probe fails if response body matches regex. fail_if_body_matches_regexp: [ - , ... ] diff --git a/config/config.go b/config/config.go index d1a7dc46..b11e6714 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,8 @@ import ( "sync" "time" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/checker/decls" yaml "gopkg.in/yaml.v3" "github.com/alecthomas/units" @@ -145,6 +147,74 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger log.Logger) (err erro return nil } +// CelProgram encapsulates a cel.Program and makes it YAML marshalable. +type CelProgram struct { + cel.Program + expression string +} + +// NewCelProgram creates a new CEL Program and returns an error if the +// passed-in CEL expression does not compile. +func NewCelProgram(s string) (CelProgram, error) { + program := CelProgram{ + expression: s, + } + + env, err := cel.NewEnv( + cel.Declarations( + decls.NewVar("body", decls.NewMapType(decls.String, decls.Dyn)), + ), + ) + if err != nil { + return program, fmt.Errorf("error creating CEL environment: %s", err) + } + + ast, issues := env.Compile(s) + if issues != nil && issues.Err() != nil { + return program, fmt.Errorf("error compiling CEL program: %s", issues.Err()) + } + + celProg, err := env.Program(ast) + if err != nil { + return program, fmt.Errorf("error creating CEL program: %s", err) + } + + program.Program = celProg + + return program, nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *CelProgram) UnmarshalYAML(unmarshal func(interface{}) error) error { + var expr string + if err := unmarshal(&expr); err != nil { + return err + } + celProg, err := NewCelProgram(expr) + if err != nil { + return fmt.Errorf("\"Could not compile CEL program\" expression=\"%s\"", expr) + } + *c = celProg + return nil +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (c CelProgram) MarshalYAML() (interface{}, error) { + if c.expression != "" { + return c.expression, nil + } + return nil, nil +} + +// MustNewCelProgram works like NewCelProgram, but panics if the CEL expression does not compile. +func MustNewCelProgram(s string) CelProgram { + c, err := NewCelProgram(s) + if err != nil { + panic(err) + } + return c +} + // Regexp encapsulates a regexp.Regexp and makes it YAML marshalable. type Regexp struct { *regexp.Regexp @@ -216,6 +286,8 @@ type HTTPProbe struct { Headers map[string]string `yaml:"headers,omitempty"` FailIfBodyMatchesRegexp []Regexp `yaml:"fail_if_body_matches_regexp,omitempty"` FailIfBodyNotMatchesRegexp []Regexp `yaml:"fail_if_body_not_matches_regexp,omitempty"` + FailIfBodyJSONMatchesCel *CelProgram `yaml:"fail_if_body_json_matches_cel,omitempty"` + FailIfBodyJSONNotMatchesCel *CelProgram `yaml:"fail_if_body_json_not_matches_cel,omitempty"` FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches,omitempty"` FailIfHeaderNotMatchesRegexp []HeaderMatch `yaml:"fail_if_header_not_matches,omitempty"` Body string `yaml:"body,omitempty"` diff --git a/go.mod b/go.mod index dfd0220a..c7952257 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,15 @@ module github.com/prometheus/blackbox_exporter -go 1.21 +go 1.22 + +toolchain go1.22.5 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 github.com/andybalholm/brotli v1.1.0 github.com/go-kit/log v0.2.1 + github.com/google/cel-go v0.20.1 github.com/miekg/dns v1.1.61 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 @@ -19,6 +22,7 @@ require ( ) require ( + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -27,14 +31,17 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/crypto v0.25.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.18.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/tools v0.22.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index ac6148ea..df399362 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4 github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -18,6 +20,8 @@ github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBj github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84= +github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -46,14 +50,19 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= @@ -68,6 +77,8 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= diff --git a/prober/http.go b/prober/http.go index d79e8e1c..5c748863 100644 --- a/prober/http.go +++ b/prober/http.go @@ -18,6 +18,7 @@ import ( "compress/gzip" "context" "crypto/tls" + "encoding/json" "errors" "fmt" "io" @@ -36,6 +37,7 @@ import ( "github.com/andybalholm/brotli" "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/google/cel-go/cel" "github.com/prometheus/client_golang/prometheus" pconfig "github.com/prometheus/common/config" "github.com/prometheus/common/version" @@ -65,6 +67,58 @@ func matchRegularExpressions(reader io.Reader, httpConfig config.HTTPProbe, logg return true } +func matchCelExpressions(reader io.Reader, httpConfig config.HTTPProbe, logger log.Logger) bool { + body, err := io.ReadAll(reader) + if err != nil { + level.Error(logger).Log("msg", "Error reading HTTP body", "err", err) + return false + } + + bodyJSON := make(map[string]interface{}) + if err := json.Unmarshal(body, &bodyJSON); err != nil { + level.Error(logger).Log("msg", "Error unmarshalling HTTP body", "err", err) + return false + } + + evalPayload := map[string]interface{}{ + "body": bodyJSON, + } + + if httpConfig.FailIfBodyJSONMatchesCel != nil { + result, details, err := httpConfig.FailIfBodyJSONMatchesCel.Eval(evalPayload) + if err != nil { + level.Error(logger).Log("msg", "Error evaluating CEL expression", "err", err) + return false + } + if result.Type() != cel.BoolType { + level.Error(logger).Log("msg", "CEL evaluation result is not a boolean", "details", details) + return false + } + if result.Type() == cel.BoolType && result.Value().(bool) { + level.Error(logger).Log("msg", "Body matched CEL expression", "expression", httpConfig.FailIfBodyJSONMatchesCel) + return false + } + } + + if httpConfig.FailIfBodyJSONNotMatchesCel != nil { + result, details, err := httpConfig.FailIfBodyJSONNotMatchesCel.Eval(evalPayload) + if err != nil { + level.Error(logger).Log("msg", "Error evaluating CEL expression", "err", err) + return false + } + if result.Type() != cel.BoolType { + level.Error(logger).Log("msg", "CEL evaluation result is not a boolean", "details", details) + return false + } + if result.Type() == cel.BoolType && !result.Value().(bool) { + level.Error(logger).Log("msg", "Body did not match CEL expression", "expression", httpConfig.FailIfBodyJSONNotMatchesCel) + return false + } + } + + return true +} + func matchRegularExpressionsOnHeaders(header http.Header, httpConfig config.HTTPProbe, logger log.Logger) bool { for _, headerMatchSpec := range httpConfig.FailIfHeaderMatchesRegexp { values := header[textproto.CanonicalMIMEHeaderKey(headerMatchSpec.Header)] @@ -297,6 +351,11 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr Help: "Indicates if probe failed due to regex", }) + probeFailedDueToCel = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "probe_failed_due_to_cel", + Help: "Indicates if probe failed due to CEL", + }) + probeHTTPLastModified = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "probe_http_last_modified_timestamp_seconds", Help: "Returns the Last-Modified HTTP response header in unixtime", @@ -311,6 +370,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr registry.MustRegister(statusCodeGauge) registry.MustRegister(probeHTTPVersionGauge) registry.MustRegister(probeFailedDueToRegex) + registry.MustRegister(probeFailedDueToCel) httpConfig := module.HTTP @@ -548,6 +608,15 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr } } + if success && (httpConfig.FailIfBodyJSONMatchesCel != nil || httpConfig.FailIfBodyJSONNotMatchesCel != nil) { + success = matchCelExpressions(byteCounter, httpConfig, logger) + if success { + probeFailedDueToCel.Set(0) + } else { + probeFailedDueToCel.Set(1) + } + } + if !requestErrored { _, err = io.Copy(io.Discard, byteCounter) if err != nil { diff --git a/prober/http_test.go b/prober/http_test.go index 7427458a..a9d8359a 100644 --- a/prober/http_test.go +++ b/prober/http_test.go @@ -926,6 +926,144 @@ func TestFailIfNotSSLLogMsg(t *testing.T) { } } +func TestFailIfBodyMatchesCel(t *testing.T) { + testcases := map[string]struct { + respBody string + cel config.CelProgram + expectedResult bool + }{ + "cel matches": { + respBody: `{"foo": {"bar": "baz"}}`, + cel: config.MustNewCelProgram("body.foo.bar == 'baz'"), + expectedResult: false, + }, + "cel does not match": { + respBody: `{"foo": {"bar": "baz"}}`, + cel: config.MustNewCelProgram("body.foo.bar == 'qux'"), + expectedResult: true, + }, + "cel does not match with empty body": { + respBody: `{}`, + cel: config.MustNewCelProgram("body.foo.bar == 'qux'"), + expectedResult: false, + }, + "cel result not boolean": { + respBody: `{"foo": {"bar": "baz"}}`, + cel: config.MustNewCelProgram("body.foo.bar"), + expectedResult: false, + }, + "body is not json": { + respBody: "hello world", + cel: config.MustNewCelProgram("body.foo.bar == 'baz'"), + expectedResult: false, + }, + } + + for name, testcase := range testcases { + t.Run(name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, testcase.respBody) + })) + defer ts.Close() + + recorder := httptest.NewRecorder() + registry := prometheus.NewRegistry() + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result := ProbeHTTP(testCTX, ts.URL, config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyJSONMatchesCel: &testcase.cel}}, registry, log.NewNopLogger()) + if testcase.expectedResult && !result { + t.Fatalf("CEL test failed unexpectedly, got %s", recorder.Body.String()) + } else if !testcase.expectedResult && result { + t.Fatalf("CEL test succeeded unexpectedly, got %s", recorder.Body.String()) + } + mfs, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + boolToFloat := func(v bool) float64 { + if v { + return 1 + } + return 0 + } + expectedResults := map[string]float64{ + "probe_failed_due_to_cel": boolToFloat(!testcase.expectedResult), + "probe_http_content_length": float64(len(testcase.respBody)), // Issue #673: check that this is correctly populated when using regex validations. + "probe_http_uncompressed_body_length": float64(len(testcase.respBody)), // Issue #673, see above. + } + checkRegistryResults(expectedResults, mfs, t) + }) + } +} + +func TestFailIfBodyNotMatchesCel(t *testing.T) { + testcases := map[string]struct { + respBody string + cel config.CelProgram + expectedResult bool + }{ + "cel matches": { + respBody: `{"foo": {"bar": "baz"}}`, + cel: config.MustNewCelProgram("body.foo.bar == 'baz'"), + expectedResult: true, + }, + "cel does not match": { + respBody: `{"foo": {"bar": "baz"}}`, + cel: config.MustNewCelProgram("body.foo.bar == 'qux'"), + expectedResult: false, + }, + "cel does not match with empty body": { + respBody: `{}`, + cel: config.MustNewCelProgram("body.foo.bar == 'qux'"), + expectedResult: false, + }, + "cel result not boolean": { + respBody: `{"foo": {"bar": "baz"}}`, + cel: config.MustNewCelProgram("body.foo.bar"), + expectedResult: false, + }, + "body is not json": { + respBody: "hello world", + cel: config.MustNewCelProgram("body.foo.bar == 'baz'"), + expectedResult: false, + }, + } + + for name, testcase := range testcases { + t.Run(name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, testcase.respBody) + })) + defer ts.Close() + + recorder := httptest.NewRecorder() + registry := prometheus.NewRegistry() + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result := ProbeHTTP(testCTX, ts.URL, config.Module{Timeout: time.Second, HTTP: config.HTTPProbe{IPProtocolFallback: true, FailIfBodyJSONNotMatchesCel: &testcase.cel}}, registry, log.NewNopLogger()) + if testcase.expectedResult && !result { + t.Fatalf("CEL test failed unexpectedly, got %s", recorder.Body.String()) + } else if !testcase.expectedResult && result { + t.Fatalf("CEL test succeeded unexpectedly, got %s", recorder.Body.String()) + } + mfs, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + boolToFloat := func(v bool) float64 { + if v { + return 1 + } + return 0 + } + expectedResults := map[string]float64{ + "probe_failed_due_to_cel": boolToFloat(!testcase.expectedResult), + } + checkRegistryResults(expectedResults, mfs, t) + }) + } +} + func TestFailIfBodyMatchesRegexp(t *testing.T) { testcases := map[string]struct { respBody string