Skip to content

Commit

Permalink
Support matching JSON body with CEL expressions
Browse files Browse the repository at this point in the history
Signed-off-by: Juho Majasaari <[email protected]>
  • Loading branch information
juho9000 committed Aug 8, 2024
1 parent 3dd5dfe commit 59ff2b4
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 1 deletion.
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 @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
)
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 @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
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 @@ -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"
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 59ff2b4

Please sign in to comment.