Skip to content

Commit

Permalink
Support matching JSON body with CEL expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
juho9000 committed Jun 5, 2024
1 parent 909dd33 commit 45e8b9c
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 0 deletions.
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_matches_cel: <cel expression, root field is called body>

# Probe fails if response body JSON does not match CEL:
fail_if_body_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
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -27,15 +29,18 @@ 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
golang.org/x/sys v0.20.0 // indirect
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
)
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.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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
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 45e8b9c

Please sign in to comment.