diff --git a/pkg/policies/engine/rego/httpsend.go b/pkg/policies/engine/rego/httpsend.go new file mode 100644 index 000000000..3e4086e79 --- /dev/null +++ b/pkg/policies/engine/rego/httpsend.go @@ -0,0 +1,89 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rego + +import ( + "context" + "fmt" + "net/url" + "slices" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" +) + +// allowedHTTPSendSchemes are the URL schemes http.send may use. Other schemes, +// such as unix, make the evaluating process connect to local resources. +var allowedHTTPSendSchemes = []string{"http", "https"} + +var httpSendURLKey = ast.StringTerm("url") + +// restrictedHTTPSendOptions are http.send request options that make the +// evaluating process read local files or environment variables. +var restrictedHTTPSendOptions = []*ast.Term{ + ast.StringTerm("tls_ca_cert_file"), + ast.StringTerm("tls_ca_cert_env_variable"), + ast.StringTerm("tls_client_cert_file"), + ast.StringTerm("tls_client_cert_env_variable"), + ast.StringTerm("tls_client_key_file"), + ast.StringTerm("tls_client_key_env_variable"), +} + +type permissiveModeCtxKey struct{} + +// withPermissiveMode marks the evaluation context so that http.send accepts +// the restricted options. Any evaluation without this mark rejects them. +func withPermissiveMode(ctx context.Context) context.Context { + return context.WithValue(ctx, permissiveModeCtxKey{}, true) +} + +func isPermissiveMode(ctx context.Context) bool { + permissive, _ := ctx.Value(permissiveModeCtxKey{}).(bool) + return permissive +} + +// OPA resolves built-ins from a global registry, so http.send can only be +// restricted by replacing its implementation there. +func init() { + httpSend := topdown.GetBuiltin(ast.HTTPSend.Name) + topdown.RegisterBuiltinFunc(ast.HTTPSend.Name, func(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + if !isPermissiveMode(bctx.Context) { + if err := validateHTTPSendRequest(operands); err != nil { + return err + } + } + return httpSend(bctx, operands, iter) + }) +} + +func validateHTTPSendRequest(operands []*ast.Term) error { + req, ok := operands[0].Value.(ast.Object) + if !ok { + return nil + } + if urlTerm := req.Get(httpSendURLKey); urlTerm != nil { + rawURL, _ := urlTerm.Value.(ast.String) + if u, err := url.Parse(string(rawURL)); err == nil && !slices.Contains(allowedHTTPSendSchemes, u.Scheme) { + return fmt.Errorf("url scheme %q is not allowed", u.Scheme) + } + } + for _, opt := range restrictedHTTPSendOptions { + if req.Get(opt) != nil { + return fmt.Errorf("request option %v is not allowed", opt) + } + } + return nil +} diff --git a/pkg/policies/engine/rego/httpsend_test.go b/pkg/policies/engine/rego/httpsend_test.go new file mode 100644 index 000000000..ba4b46699 --- /dev/null +++ b/pkg/policies/engine/rego/httpsend_test.go @@ -0,0 +1,157 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rego + +import ( + "context" + "fmt" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/policies/engine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const httpSendPolicyTemplate = `package main + +import rego.v1 + +result := {"skipped": false, "violations": violations} + +violations contains "request sent" if { + http.send(%[1]s) +} + +matches_parameters if { + http.send(%[1]s) +} +` + +func TestRego_HTTPSendRestrictedOptions(t *testing.T) { + const allowedURL = `"url": "https://www.chainloop.dev"` + + testCases := []struct { + name string + request string + wantErr string + }{ + { + name: "tls_ca_cert_file", + request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_file": "/etc/ssl/ca.pem"}`, + wantErr: `http.send: request option "tls_ca_cert_file" is not allowed`, + }, + { + name: "tls_ca_cert_env_variable", + request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_env_variable": "CA_CERT"}`, + wantErr: `http.send: request option "tls_ca_cert_env_variable" is not allowed`, + }, + { + name: "tls_client_cert_file", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_cert_file": "/etc/ssl/cert.pem"}`, + wantErr: `http.send: request option "tls_client_cert_file" is not allowed`, + }, + { + name: "tls_client_cert_env_variable", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_cert_env_variable": "CLIENT_CERT"}`, + wantErr: `http.send: request option "tls_client_cert_env_variable" is not allowed`, + }, + { + name: "tls_client_key_file", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_key_file": "/etc/ssl/key.pem"}`, + wantErr: `http.send: request option "tls_client_key_file" is not allowed`, + }, + { + name: "tls_client_key_env_variable", + request: `{"method": "GET", ` + allowedURL + `, "tls_client_key_env_variable": "CLIENT_KEY"}`, + wantErr: `http.send: request option "tls_client_key_env_variable" is not allowed`, + }, + { + name: "empty value is rejected", + request: `{"method": "GET", ` + allowedURL + `, "tls_ca_cert_file": ""}`, + wantErr: `http.send: request option "tls_ca_cert_file" is not allowed`, + }, + { + name: "option key built at runtime", + request: `object.union({"method": "GET", ` + allowedURL + `}, {concat("_", ["tls", "client", "key", "file"]): "/etc/ssl/key.pem"})`, + wantErr: `http.send: request option "tls_client_key_file" is not allowed`, + }, + { + name: "unix scheme with an allowed host", + request: `{"method": "GET", "url": "unix://www.chainloop.dev/info?socket=%2Fvar%2Frun%2Fapp.sock"}`, + wantErr: `http.send: url scheme "unix" is not allowed`, + }, + { + name: "url without scheme", + request: `{"method": "GET", "url": "www.chainloop.dev"}`, + wantErr: `http.send: url scheme "" is not allowed`, + }, + { + name: "upper case https scheme reaches http.send", + request: `{"method": "GET", "url": "HTTPS://example.com"}`, + wantErr: "http.send: disallowed host: example.com", + }, + { + name: "request without restricted options reaches http.send", + request: `{"method": "GET", "url": "https://example.com"}`, + wantErr: "http.send: disallowed host: example.com", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := NewEngine() + policy := &engine.Policy{ + Name: "http-send", + Source: fmt.Appendf(nil, httpSendPolicyTemplate, tc.request), + } + + _, verifyErr := r.Verify(context.TODO(), policy, []byte(`{}`), nil) + _, matchErr := r.MatchesParameters(context.TODO(), policy, nil, nil) + + for _, err := range []error{verifyErr, matchErr} { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } + }) + } +} + +func TestRego_HTTPSendPermissiveMode(t *testing.T) { + // raise_error=false makes http.send return its own error in the response, + // which shows that the request got past the restriction. + const policySource = `package main + +import rego.v1 + +result := {"skipped": false, "violations": violations} + +violations contains resp.error.message if { + resp := http.send({ + "method": "GET", + "url": "unix://www.chainloop.dev/info?socket=%2Fnonexistent%2Fapp.sock", + "tls_ca_cert_file": "/nonexistent/ca.pem", + "raise_error": false, + }) +} +` + + r := NewEngine(engine.WithOperatingMode(int32(EnvironmentModePermissive))) + result, err := r.Verify(context.TODO(), &engine.Policy{Name: "http-send", Source: []byte(policySource)}, []byte(`{}`), nil) + require.NoError(t, err) + require.Len(t, result.Violations, 1) + assert.Contains(t, result.Violations[0].Violation, "/nonexistent/ca.pem") + assert.NotContains(t, result.Violations[0].Violation, "is not allowed") +} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index f923e4fba..9b718defe 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -144,6 +144,7 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte // wins, since context.WithTimeout never extends the parent's. ctx, cancel := context.WithTimeout(ctx, r.executionTimeout) defer cancel() + ctx = r.evalContext(ctx) policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) @@ -452,11 +453,21 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v return matchesEvaluation, nil } +// evalContext marks the context of permissive engines, which lifts the +// http.send request option restrictions. +func (r *Engine) evalContext(ctx context.Context) context.Context { + if r.operatingMode == EnvironmentModePermissive { + return withPermissiveMode(ctx) + } + return ctx +} + // Evaluates a single rule and returns its boolean result func (r *Engine) evaluateMatchingRule(ctx context.Context, ruleName string, parsedModule *ast.Module, decodedInput interface{}) (result bool, found bool, err error) { // Bound the evaluation, same as Verify does for the main rule ctx, cancel := context.WithTimeout(ctx, r.executionTimeout) defer cancel() + ctx = r.evalContext(ctx) // Add input regoInput := rego.Input(decodedInput)