Skip to content

Commit f2078be

Browse files
juho9000dgl
andauthored
Support matching JSON body with CEL expressions (#1255)
* Support matching JSON body with CEL expressions * Use ContextEval with Cel to ensure timeouts are respected * Log Cel expressions on failures * bump cel-go version * improve docs and add example on cel usage * improve logging on json unmarshalling failure * add cel failure metric only if cel options are used * use consistent naming, add tests * add tests * support other json types besides just objects * fixup! improve docs and add example on cel usage --------- Signed-off-by: Juho Majasaari <[email protected]> Co-authored-by: David Leadbeater <[email protected]>
1 parent c220159 commit f2078be

File tree

8 files changed

+417
-0
lines changed

8 files changed

+417
-0
lines changed

CONFIGURATION.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ modules:
8989
# Probe fails if SSL is not present.
9090
[ fail_if_not_ssl: <boolean> | default = false ]
9191

92+
# Probe fails if response body JSON matches the CEL expression or if response is not JSON. See: https://github.com/google/cel-spec:
93+
fail_if_body_json_matches_cel: <string>
94+
95+
# Probe fails if response body JSON does not match CEL expression or if response is not JSON. See: https://github.com/google/cel-spec:
96+
fail_if_body_json_not_matches_cel: <string>
97+
9298
# Probe fails if response body matches regex.
9399
fail_if_body_matches_regexp:
94100
[ - <regex>, ... ]

config/config.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"sync"
2929
"time"
3030

31+
"github.com/google/cel-go/cel"
3132
yaml "gopkg.in/yaml.v3"
3233

3334
"github.com/alecthomas/units"
@@ -144,6 +145,72 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er
144145
return nil
145146
}
146147

148+
// CELProgram encapsulates a cel.Program and makes it YAML marshalable.
149+
type CELProgram struct {
150+
cel.Program
151+
Expression string
152+
}
153+
154+
// NewCELProgram creates a new CEL Program and returns an error if the
155+
// passed-in CEL expression does not compile.
156+
func NewCELProgram(s string) (CELProgram, error) {
157+
program := CELProgram{
158+
Expression: s,
159+
}
160+
161+
env, err := cel.NewEnv(
162+
cel.Variable("body", cel.DynType),
163+
)
164+
if err != nil {
165+
return program, fmt.Errorf("error creating CEL environment: %s", err)
166+
}
167+
168+
ast, issues := env.Compile(s)
169+
if issues != nil && issues.Err() != nil {
170+
return program, fmt.Errorf("error compiling CEL program: %s", issues.Err())
171+
}
172+
173+
celProg, err := env.Program(ast, cel.InterruptCheckFrequency(100))
174+
if err != nil {
175+
return program, fmt.Errorf("error creating CEL program: %s", err)
176+
}
177+
178+
program.Program = celProg
179+
180+
return program, nil
181+
}
182+
183+
// UnmarshalYAML implements the yaml.Unmarshaler interface.
184+
func (c *CELProgram) UnmarshalYAML(unmarshal func(interface{}) error) error {
185+
var expr string
186+
if err := unmarshal(&expr); err != nil {
187+
return err
188+
}
189+
celProg, err := NewCELProgram(expr)
190+
if err != nil {
191+
return fmt.Errorf("\"Could not compile CEL program\" expression=\"%s\"", expr)
192+
}
193+
*c = celProg
194+
return nil
195+
}
196+
197+
// MarshalYAML implements the yaml.Marshaler interface.
198+
func (c CELProgram) MarshalYAML() (interface{}, error) {
199+
if c.Expression != "" {
200+
return c.Expression, nil
201+
}
202+
return nil, nil
203+
}
204+
205+
// MustNewCELProgram works like NewCELProgram, but panics if the CEL expression does not compile.
206+
func MustNewCELProgram(s string) CELProgram {
207+
c, err := NewCELProgram(s)
208+
if err != nil {
209+
panic(err)
210+
}
211+
return c
212+
}
213+
147214
// Regexp encapsulates a regexp.Regexp and makes it YAML marshalable.
148215
type Regexp struct {
149216
*regexp.Regexp
@@ -215,6 +282,8 @@ type HTTPProbe struct {
215282
Headers map[string]string `yaml:"headers,omitempty"`
216283
FailIfBodyMatchesRegexp []Regexp `yaml:"fail_if_body_matches_regexp,omitempty"`
217284
FailIfBodyNotMatchesRegexp []Regexp `yaml:"fail_if_body_not_matches_regexp,omitempty"`
285+
FailIfBodyJsonMatchesCEL *CELProgram `yaml:"fail_if_body_json_matches_cel,omitempty"`
286+
FailIfBodyJsonNotMatchesCEL *CELProgram `yaml:"fail_if_body_json_not_matches_cel,omitempty"`
218287
FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches,omitempty"`
219288
FailIfHeaderNotMatchesRegexp []HeaderMatch `yaml:"fail_if_header_not_matches,omitempty"`
220289
Body string `yaml:"body,omitempty"`

config/config_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,36 @@ func TestIsEncodingAcceptable(t *testing.T) {
213213
})
214214
}
215215
}
216+
217+
func TestNewCELProgram(t *testing.T) {
218+
tests := []struct {
219+
name string
220+
expr string
221+
wantErr bool
222+
}{
223+
{
224+
name: "valid expression",
225+
expr: "body.foo == 'bar'",
226+
wantErr: false,
227+
},
228+
{
229+
name: "invalid expression",
230+
expr: "foo.bar",
231+
wantErr: true,
232+
},
233+
{
234+
name: "empty expression",
235+
expr: "",
236+
wantErr: true,
237+
},
238+
}
239+
for _, tt := range tests {
240+
t.Run(tt.name, func(t *testing.T) {
241+
_, err := NewCELProgram(tt.expr)
242+
if (err != nil) != tt.wantErr {
243+
t.Errorf("NewCELProgram() error = %v, wantErr %v", err, tt.wantErr)
244+
return
245+
}
246+
})
247+
}
248+
}

example.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ modules:
6464
basic_auth:
6565
username: "username"
6666
password: "mysecret"
67+
http_json_cel_match:
68+
prober: http
69+
timeout: 5s
70+
http:
71+
method: GET
72+
fail_if_body_json_not_matches_cel: "body.foo == 'bar' && body.baz.startsWith('q')" # { "foo": "bar", "baz": "qux" }
6773
http_2xx_oauth_client_credentials:
6874
prober: http
6975
timeout: 5s

go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/alecthomas/kingpin/v2 v2.4.0
77
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9
88
github.com/andybalholm/brotli v1.1.1
9+
github.com/google/cel-go v0.24.0
910
github.com/miekg/dns v1.1.63
1011
github.com/prometheus/client_golang v1.20.5
1112
github.com/prometheus/client_model v0.6.1
@@ -18,6 +19,8 @@ require (
1819
)
1920

2021
require (
22+
cel.dev/expr v0.19.1 // indirect
23+
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
2124
github.com/beorn7/perks v1.0.1 // indirect
2225
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2326
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
@@ -28,14 +31,17 @@ require (
2831
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
2932
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
3033
github.com/prometheus/procfs v0.15.1 // indirect
34+
github.com/stoewer/go-strcase v1.2.0 // indirect
3135
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
3236
golang.org/x/crypto v0.33.0 // indirect
37+
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
3338
golang.org/x/mod v0.18.0 // indirect
3439
golang.org/x/oauth2 v0.24.0 // indirect
3540
golang.org/x/sync v0.11.0 // indirect
3641
golang.org/x/sys v0.30.0 // indirect
3742
golang.org/x/text v0.22.0 // indirect
3843
golang.org/x/tools v0.22.0 // indirect
44+
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a // indirect
3945
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
4046
google.golang.org/protobuf v1.36.1 // indirect
4147
)

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
2+
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
13
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
24
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
35
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs=
46
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
57
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
68
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
9+
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
10+
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
711
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
812
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
913
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -20,6 +24,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
2024
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
2125
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
2226
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
27+
github.com/google/cel-go v0.24.0 h1:2K5N+wjlzhvmznGd31j4gHE3XYQhW4CES8rgcgGEzAA=
28+
github.com/google/cel-go v0.24.0/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
2329
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
2430
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
2531
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -58,8 +64,11 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
5864
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
5965
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
6066
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
67+
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
68+
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
6169
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
6270
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
71+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
6372
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6473
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
6574
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
@@ -78,6 +87,8 @@ go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQD
7887
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
7988
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
8089
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
90+
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
91+
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
8192
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
8293
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
8394
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
@@ -92,6 +103,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
92103
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
93104
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
94105
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
106+
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a h1:OAiGFfOiA0v9MRYsSidp3ubZaBnteRUyn3xB2ZQ5G/E=
107+
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a/go.mod h1:jehYqy3+AhJU9ve55aNOaSml7wUXjF9x6z2LcCfpAhY=
95108
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o=
96109
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
97110
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=

prober/http.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"compress/gzip"
1919
"context"
2020
"crypto/tls"
21+
"encoding/json"
2122
"errors"
2223
"fmt"
2324
"io"
@@ -35,6 +36,7 @@ import (
3536
"time"
3637

3738
"github.com/andybalholm/brotli"
39+
"github.com/google/cel-go/cel"
3840
"github.com/prometheus/client_golang/prometheus"
3941
pconfig "github.com/prometheus/common/config"
4042
"github.com/prometheus/common/version"
@@ -64,6 +66,58 @@ func matchRegularExpressions(reader io.Reader, httpConfig config.HTTPProbe, logg
6466
return true
6567
}
6668

69+
func matchCELExpressions(ctx context.Context, reader io.Reader, httpConfig config.HTTPProbe, logger *slog.Logger) bool {
70+
body, err := io.ReadAll(reader)
71+
if err != nil {
72+
logger.Error("Error reading HTTP body", "err", err)
73+
return false
74+
}
75+
76+
var bodyJSON any
77+
if err := json.Unmarshal(body, &bodyJSON); err != nil {
78+
logger.Error("Error unmarshalling HTTP body to JSON", "err", err)
79+
return false
80+
}
81+
82+
evalPayload := map[string]interface{}{
83+
"body": bodyJSON,
84+
}
85+
86+
if httpConfig.FailIfBodyJsonMatchesCEL != nil {
87+
result, details, err := httpConfig.FailIfBodyJsonMatchesCEL.ContextEval(ctx, evalPayload)
88+
if err != nil {
89+
logger.Error("Error evaluating CEL expression", "err", err)
90+
return false
91+
}
92+
if result.Type() != cel.BoolType {
93+
logger.Error("CEL evaluation result is not a boolean", "details", details)
94+
return false
95+
}
96+
if result.Type() == cel.BoolType && result.Value().(bool) {
97+
logger.Error("Body matched CEL expression", "expression", httpConfig.FailIfBodyJsonMatchesCEL.Expression)
98+
return false
99+
}
100+
}
101+
102+
if httpConfig.FailIfBodyJsonNotMatchesCEL != nil {
103+
result, details, err := httpConfig.FailIfBodyJsonNotMatchesCEL.ContextEval(ctx, evalPayload)
104+
if err != nil {
105+
logger.Error("Error evaluating CEL expression", "err", err)
106+
return false
107+
}
108+
if result.Type() != cel.BoolType {
109+
logger.Error("CEL evaluation result is not a boolean", "details", details)
110+
return false
111+
}
112+
if result.Type() == cel.BoolType && !result.Value().(bool) {
113+
logger.Error("Body did not match CEL expression", "expression", httpConfig.FailIfBodyJsonNotMatchesCEL.Expression)
114+
return false
115+
}
116+
}
117+
118+
return true
119+
}
120+
67121
func matchRegularExpressionsOnHeaders(header http.Header, httpConfig config.HTTPProbe, logger *slog.Logger) bool {
68122
for _, headerMatchSpec := range httpConfig.FailIfHeaderMatchesRegexp {
69123
values := header[textproto.CanonicalMIMEHeaderKey(headerMatchSpec.Header)]
@@ -296,6 +350,11 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
296350
Help: "Indicates if probe failed due to regex",
297351
})
298352

353+
probeFailedDueToCEL = prometheus.NewGauge(prometheus.GaugeOpts{
354+
Name: "probe_failed_due_to_cel",
355+
Help: "Indicates if probe failed due to CEL expression not matching",
356+
})
357+
299358
probeHTTPLastModified = prometheus.NewGauge(prometheus.GaugeOpts{
300359
Name: "probe_http_last_modified_timestamp_seconds",
301360
Help: "Returns the Last-Modified HTTP response header in unixtime",
@@ -313,6 +372,10 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
313372

314373
httpConfig := module.HTTP
315374

375+
if httpConfig.FailIfBodyJsonMatchesCEL != nil || httpConfig.FailIfBodyJsonNotMatchesCEL != nil {
376+
registry.MustRegister(probeFailedDueToCEL)
377+
}
378+
316379
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
317380
target = "http://" + target
318381
}
@@ -547,6 +610,15 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
547610
}
548611
}
549612

613+
if success && (httpConfig.FailIfBodyJsonMatchesCEL != nil || httpConfig.FailIfBodyJsonNotMatchesCEL != nil) {
614+
success = matchCELExpressions(ctx, byteCounter, httpConfig, logger)
615+
if success {
616+
probeFailedDueToCEL.Set(0)
617+
} else {
618+
probeFailedDueToCEL.Set(1)
619+
}
620+
}
621+
550622
if !requestErrored {
551623
_, err = io.Copy(io.Discard, byteCounter)
552624
if err != nil {

0 commit comments

Comments
 (0)