-
Notifications
You must be signed in to change notification settings - Fork 0
feat(engine): inspect request body in policy rules #7
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
Changes from all commits
b57f87b
f355e81
00baf08
d920432
5db6a91
6cb54ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -41,6 +41,28 @@ func NewHTTPCall(method, host, path string) Call { | |||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // NewHTTPCallWithBody constructs a Call for HTTP request policy evaluation, | ||||||||||||||||
| // including the parsed request body under params.body. It behaves exactly like | ||||||||||||||||
| // NewHTTPCall (method is uppercased, path is expected to include a leading | ||||||||||||||||
| // slash, Context.Scope is left unset) but additionally exposes the body to | ||||||||||||||||
| // rules that inspect it (e.g. `params.body.model == 'gpt-4'` or | ||||||||||||||||
| // `hasSecrets(params.body.prompt)`). | ||||||||||||||||
| // | ||||||||||||||||
| // body is the decoded request body and may be any JSON-shaped value: an object | ||||||||||||||||
| // (map[string]any), an array ([]any), or a scalar. It is stored as-is, so a nil | ||||||||||||||||
| // body still sets params.body (to nil). With a nil body, content-inspection | ||||||||||||||||
| // rules such as params.body.model == 'gpt-4' will not match, because CEL treats | ||||||||||||||||
| // the nil navigation as a missing value and the engine resolves that to false; | ||||||||||||||||
| // has(params.body) is nonetheless always true for calls built with this helper, | ||||||||||||||||
| // so test for a populated body with params.body != null instead. Use | ||||||||||||||||
| // Engine.RequiresBody to decide whether buffering and parsing the body is | ||||||||||||||||
| // worthwhile before calling this. | ||||||||||||||||
| func NewHTTPCallWithBody(method, host, path string, body any) Call { | ||||||||||||||||
| call := NewHTTPCall(method, host, path) | ||||||||||||||||
| call.Params[engine.ParamBody] = body | ||||||||||||||||
| return call | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Passing a nil body sets Worth adding a sentence to the doc comment that makes this explicit: "Passing a nil body means body-inspection rules will not match, since CEL treats nil as a missing value." That way callers understand the consequence of buffering but finding no body to parse. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The The nil-guard advice should just be
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Applied in 5db6a91. The inaccurate `size(params.body)` hint is gone; the doc now recommends `params.body != null`. Verified: with a nil body it evaluates false, with a populated body true, and with an absent body false. |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // NewMCPCall constructs a Call for MCP tool-use policy evaluation. | ||||||||||||||||
| // The operation is the tool name as-is. Params are passed through directly (may be nil). | ||||||||||||||||
| // Context.Scope is not set — callers should assign it based on their deployment convention. | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ import ( | |
| "time" | ||
|
|
||
| "github.com/google/cel-go/cel" | ||
| celast "github.com/google/cel-go/common/ast" | ||
| "github.com/google/cel-go/common/operators" | ||
| "github.com/google/cel-go/common/types" | ||
| "github.com/google/cel-go/common/types/ref" | ||
| "github.com/google/cel-go/common/types/traits" | ||
|
|
@@ -277,6 +279,13 @@ func NewEnv(opts ...EnvOption) (*Env, error) { | |
| // Program is a compiled CEL expression ready for evaluation. | ||
| type Program struct { | ||
| prog cel.Program | ||
| // paramRefs is the set of top-level fields referenced off the params | ||
| // input variable (e.g. "body", "headers"), computed once at compile time. | ||
| paramRefs map[string]bool | ||
| // paramsOpaque is set when the expression uses the params map in a way the | ||
| // syntactic walker cannot resolve to specific fields (see collectParamRefs). | ||
| // When true, ReferencesParam answers true for every field — failing safe. | ||
| paramsOpaque bool | ||
| } | ||
|
|
||
| // Compile parses and type-checks a CEL expression string. | ||
|
|
@@ -290,7 +299,89 @@ func (e *Env) Compile(expr string) (*Program, error) { | |
| if err != nil { | ||
| return nil, fmt.Errorf("cel: program %q: %w", expr, err) | ||
| } | ||
| return &Program{prog: prog}, nil | ||
| refs, opaque := collectParamRefs(ast.NativeRep()) | ||
| return &Program{prog: prog, paramRefs: refs, paramsOpaque: opaque}, nil | ||
| } | ||
|
|
||
| // ReferencesParam reports whether the compiled expression may read the given | ||
| // field off the params input variable. It returns true for idiomatic dot- or | ||
| // index-access (params.body, params["body"], including nested forms like | ||
| // params.body.foo), and — failing safe — for every field when the expression | ||
| // touches the params map opaquely (see collectParamRefs). The result is | ||
| // computed at compile time, so this is a cheap lookup. | ||
| func (p *Program) ReferencesParam(field string) bool { | ||
| if p == nil { | ||
| return false | ||
| } | ||
| return p.paramsOpaque || p.paramRefs[field] | ||
| } | ||
|
|
||
| // collectParamRefs walks a compiled AST and returns the set of top-level fields | ||
| // read off the params input variable, plus an "opaque" flag. It recognizes both | ||
| // dot-selection (params.body) and index access with a string literal | ||
| // (params["body"]), including those nested inside macros (has, exists, map, | ||
| // filter), the `in` operator, and ternaries — i.e. every idiomatic way a rule | ||
| // reads a field. | ||
| // | ||
| // Because it is a purely syntactic matcher, it cannot resolve a field read | ||
| // through a computed index key (params["bo"+"dy"]), the whole params map used as | ||
| // a value (size(params), dyn(params).body), or params rebound to a comprehension | ||
| // variable. Rather than silently report "no reference" for those — which would | ||
| // let a fail-safe trigger (see Engine.RequiresBody) skip a field the rule | ||
| // actually reads — it returns opaque=true whenever params is used in any form | ||
| // other than a recognized field access. Callers then treat the expression as | ||
| // potentially referencing any field. | ||
| // | ||
| // Detection: every occurrence of the bare params identifier is either consumed | ||
| // by a recognized field access (a select on params, or an index of params with a | ||
| // string-literal key) or it is not. If any occurrence is left unconsumed, params | ||
| // escaped into an unresolvable position and opaque is set. | ||
| // | ||
| // Coupled to cel-go/common/ast (PostOrderVisit, SelectKind/CallKind, the index | ||
| // operators); audit this walk when upgrading cel-go. | ||
| func collectParamRefs(a *celast.AST) (refs map[string]bool, opaque bool) { | ||
| if a == nil { | ||
| return nil, false | ||
| } | ||
| refs = map[string]bool{} | ||
| var total, consumed int | ||
| celast.PostOrderVisit(a.Expr(), celast.NewExprVisitor(func(e celast.Expr) { | ||
| switch e.Kind() { | ||
| case celast.IdentKind: | ||
| if isParamsIdent(e) { | ||
| total++ | ||
| } | ||
| case celast.SelectKind: | ||
| sel := e.AsSelect() | ||
| if isParamsIdent(sel.Operand()) { | ||
| refs[sel.FieldName()] = true | ||
| consumed++ | ||
| } | ||
| case celast.CallKind: | ||
| call := e.AsCall() | ||
| if call.FunctionName() != operators.Index && call.FunctionName() != operators.OptIndex { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: CEL also has an optional-chaining select ( |
||
| return | ||
| } | ||
| args := call.Args() | ||
| if len(args) != 2 || !isParamsIdent(args[0]) || args[1].Kind() != celast.LiteralKind { | ||
| return | ||
| } | ||
| if s, ok := args[1].AsLiteral().Value().(string); ok { | ||
| refs[s] = true | ||
| consumed++ | ||
| } | ||
| } | ||
| })) | ||
| opaque = total > consumed | ||
| if len(refs) == 0 { | ||
| refs = nil | ||
| } | ||
| return refs, opaque | ||
| } | ||
|
|
||
| // isParamsIdent reports whether e is the bare params input identifier. | ||
| func isParamsIdent(e celast.Expr) bool { | ||
| return e.Kind() == celast.IdentKind && e.AsIdent() == "params" | ||
| } | ||
|
|
||
| // Eval evaluates a compiled program against the given params and context. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| package cel_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| keepcel "github.com/majorcontext/keep/internal/cel" | ||
| ) | ||
|
|
||
| func TestReferencesParam(t *testing.T) { | ||
| env := mustNewEnv(t) | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| expr string | ||
| field string | ||
| want bool | ||
| }{ | ||
| {"dot select", "params.body == 'x'", "body", true}, | ||
| {"index string literal", `params["body"] == 'x'`, "body", true}, | ||
| {"nested select", "params.body.model == 'gpt-4'", "body", true}, | ||
| {"index into body", "size(params.body[0]) > 0", "body", true}, | ||
| {"inside function call", "hasSecrets(params.body.prompt)", "body", true}, | ||
| {"presence test", "has(params.body)", "body", true}, | ||
| {"deeply nested", "params.body.messages[0].content != ''", "body", true}, | ||
| {"other field only", "params.method == 'POST'", "body", false}, | ||
| {"different field requested", "params.body == 'x'", "headers", false}, | ||
| {"no params at all", "context.agent_id == 'a'", "body", false}, | ||
| {"field named after body substr", "params.bodyguard == 'x'", "body", false}, | ||
| // Opaque uses of the params map cannot be resolved to a field, so | ||
| // detection fails SAFE: ReferencesParam answers true for every field. | ||
| {"dynamic index key", "params[params.method] == 'x'", "body", true}, | ||
| {"computed string index key", `params["bo" + "dy"] == 'x'`, "body", true}, | ||
| {"whole-map op", "size(params) > 0", "body", true}, | ||
| {"params wrapped in dyn", "dyn(params).body == 'x'", "body", true}, | ||
| {"opaque use alongside specific field", "params.a == 'x' && size(params) > 0", "body", true}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| prog := mustCompile(t, env, tt.expr) | ||
| if got := prog.ReferencesParam(tt.field); got != tt.want { | ||
| t.Errorf("ReferencesParam(%q) on %q = %v, want %v", tt.field, tt.expr, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous review flagged optional dot-select ( {"optional dot select", "params?.body == 'x'", "body", true},If CEL represents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional dot-select ( |
||
| } | ||
|
|
||
| // TestReferencesParam_FailSafeDoesNotOverTrigger verifies that recognized | ||
| // single-field access does NOT mark the program opaque: a rule that only reads | ||
| // params.method must not report a reference to params.body (which would force a | ||
| // gatekeeper to buffer the body for every method-only rule). | ||
| func TestReferencesParam_FailSafeDoesNotOverTrigger(t *testing.T) { | ||
| env := mustNewEnv(t) | ||
| prog := mustCompile(t, env, "params.method == 'POST' && params.host == 'api.example.com'") | ||
| if prog.ReferencesParam("body") { | ||
| t.Error("ReferencesParam(\"body\") = true for a method/host-only rule, want false") | ||
| } | ||
| } | ||
|
|
||
| func TestReferencesParam_MultipleFields(t *testing.T) { | ||
| env := mustNewEnv(t) | ||
| prog := mustCompile(t, env, "params.body.model == 'gpt-4' && params.headers['x'] == 'y'") | ||
|
|
||
| for _, f := range []string{"body", "headers"} { | ||
| if !prog.ReferencesParam(f) { | ||
| t.Errorf("ReferencesParam(%q) = false, want true", f) | ||
| } | ||
| } | ||
| if prog.ReferencesParam("method") { | ||
| t.Error("ReferencesParam(\"method\") = true, want false") | ||
| } | ||
| } | ||
|
|
||
| // TestParamsBodyNilSemantics pins the CEL runtime behavior that | ||
| // NewHTTPCallWithBody's docstring relies on: a nil body is stored under the | ||
| // "body" key, so has(params.body) is true while params.body != null is false. | ||
| // This guards the documented contract against a cel-go change to nil-valued map | ||
| // key handling. | ||
| func TestParamsBodyNilSemantics(t *testing.T) { | ||
| env := mustNewEnv(t) | ||
|
|
||
| withNil := map[string]any{"body": nil} // NewHTTPCallWithBody(nil) | ||
| withVal := map[string]any{"body": map[string]any{"model": "x"}} // populated body | ||
| absent := map[string]any{} // NewHTTPCall (no body key) | ||
|
|
||
| tests := []struct { | ||
| expr string | ||
| params map[string]any | ||
| want bool | ||
| }{ | ||
| {"has(params.body)", withNil, true}, | ||
| {"has(params.body)", absent, false}, | ||
| {"params.body != null", withNil, false}, | ||
| {"params.body != null", withVal, true}, | ||
| {"params.body != null", absent, false}, | ||
| } | ||
| for _, tt := range tests { | ||
| prog := mustCompile(t, env, tt.expr) | ||
| got, err := prog.Eval(tt.params, nil) | ||
| if err != nil { | ||
| t.Fatalf("Eval(%q, %v) error: %v", tt.expr, tt.params, err) | ||
| } | ||
| if got != tt.want { | ||
| t.Errorf("Eval(%q, %v) = %v, want %v", tt.expr, tt.params, got, tt.want) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestReferencesParam_NilProgram(t *testing.T) { | ||
| // A nil *Program must not panic and reports no references. This matters | ||
| // because a rule with no when clause has a nil compiled program. | ||
| var prog *keepcel.Program | ||
| if prog.ReferencesParam("body") { | ||
| t.Error("nil program ReferencesParam = true, want false") | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.