diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 83b12f57..bea75b28 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 b665a6e1..036442ce 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 d56157eb..a6307a9c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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.58 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 @@ -19,6 +20,7 @@ require ( ) require ( + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -27,8 +29,10 @@ require ( github.com/jpillora/backoff v1.0.0 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/prometheus/procfs v0.12.0 // 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.23.0 // indirect + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/oauth2 v0.18.0 // indirect golang.org/x/sync v0.6.0 // indirect @@ -36,6 +40,7 @@ require ( golang.org/x/text v0.15.0 // indirect golang.org/x/tools v0.17.0 // indirect google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/protobuf v1.33.0 // indirect ) diff --git a/go.sum b/go.sum index 1fbe90d5..9b99e9a7 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.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 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.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -22,6 +24,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -49,8 +53,11 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 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.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= @@ -60,6 +67,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= @@ -98,6 +107,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= 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 05ca36d8..5e83fc0d 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