Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Support matching JSON body with CEL expressions #1255

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ modules:
# Probe fails if SSL is not present.
[ fail_if_not_ssl: <boolean> | default = false ]

# Probe fails if response body JSON matches CEL:
fail_if_body_json_matches_cel: <cel expression, root field is called body>

# Probe fails if response body JSON does not match CEL:
fail_if_body_json_not_matches_cel: <cel expression, root field is called body>

# Probe fails if response body matches regex.
fail_if_body_matches_regexp:
[ - <regex>, ... ]
Expand Down
72 changes: 72 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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"
Expand Down Expand Up @@ -144,6 +146,74 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er
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, cel.InterruptCheckFrequency(100))
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
Expand Down Expand Up @@ -215,6 +285,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"`
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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/google/cel-go v0.20.1
github.com/miekg/dns v1.1.62
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/client_model v0.6.1
Expand All @@ -18,6 +19,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
Expand All @@ -28,14 +30,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.28.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.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -14,6 +16,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
Expand Down Expand Up @@ -50,14 +54,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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
Expand All @@ -72,6 +81,8 @@ golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
Expand Down
69 changes: 69 additions & 0 deletions prober/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"compress/gzip"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -35,6 +36,7 @@ import (
"time"

"github.com/andybalholm/brotli"
"github.com/google/cel-go/cel"
"github.com/prometheus/client_golang/prometheus"
pconfig "github.com/prometheus/common/config"
"github.com/prometheus/common/version"
Expand Down Expand Up @@ -64,6 +66,58 @@ func matchRegularExpressions(reader io.Reader, httpConfig config.HTTPProbe, logg
return true
}

func matchCelExpressions(ctx context.Context, reader io.Reader, httpConfig config.HTTPProbe, logger *slog.Logger) bool {
body, err := io.ReadAll(reader)
if err != nil {
logger.Error("Error reading HTTP body", "err", err)
return false
}

bodyJSON := make(map[string]interface{})
if err := json.Unmarshal(body, &bodyJSON); err != nil {
logger.Error("Error unmarshalling HTTP body", "err", err)
return false
}

evalPayload := map[string]interface{}{
"body": bodyJSON,
}

if httpConfig.FailIfBodyJSONMatchesCel != nil {
result, details, err := httpConfig.FailIfBodyJSONMatchesCel.ContextEval(ctx, evalPayload)
if err != nil {
logger.Error("Error evaluating CEL expression", "err", err)
return false
}
if result.Type() != cel.BoolType {
logger.Error("CEL evaluation result is not a boolean", "details", details)
return false
}
if result.Type() == cel.BoolType && result.Value().(bool) {
logger.Error("Body matched CEL expression", "expression", httpConfig.FailIfBodyJSONMatchesCel.Expression)
return false
}
}

if httpConfig.FailIfBodyJSONNotMatchesCel != nil {
result, details, err := httpConfig.FailIfBodyJSONNotMatchesCel.ContextEval(ctx, evalPayload)
if err != nil {
logger.Error("Error evaluating CEL expression", "err", err)
return false
}
if result.Type() != cel.BoolType {
logger.Error("CEL evaluation result is not a boolean", "details", details)
return false
}
if result.Type() == cel.BoolType && !result.Value().(bool) {
logger.Error("Body did not match CEL expression", "expression", httpConfig.FailIfBodyJSONNotMatchesCel.Expression)
return false
}
}

return true
}

func matchRegularExpressionsOnHeaders(header http.Header, httpConfig config.HTTPProbe, logger *slog.Logger) bool {
for _, headerMatchSpec := range httpConfig.FailIfHeaderMatchesRegexp {
values := header[textproto.CanonicalMIMEHeaderKey(headerMatchSpec.Header)]
Expand Down Expand Up @@ -296,6 +350,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{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking this should be added conditionally, if the configuration asks for validation using CEL expressions.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was following the same logic that is applied to regex checks, following your logic that should also be conditionally added, no?

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",
Expand All @@ -310,6 +369,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

Expand Down Expand Up @@ -547,6 +607,15 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
}
}

if success && (httpConfig.FailIfBodyJSONMatchesCel != nil || httpConfig.FailIfBodyJSONNotMatchesCel != nil) {
success = matchCelExpressions(ctx, byteCounter, httpConfig, logger)
if success {
probeFailedDueToCel.Set(0)
} else {
probeFailedDueToCel.Set(1)
}
}

if !requestErrored {
_, err = io.Copy(io.Discard, byteCounter)
if err != nil {
Expand Down
Loading