From 733e6f54145ff77fe4bf182acc07e0218a7055f9 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 8 May 2026 23:13:20 +0100 Subject: [PATCH 1/5] feat: support remote http(s) URLs for --policy in kosli evaluate Allow `--policy https://host/policy.rego` (or http://) on `evaluate trail`, `evaluate trails`, and `evaluate input`. Local file paths continue to work unchanged. Remote fetches honor `--http-proxy` and warn on plain HTTP. --- cmd/kosli/evaluateHelpers.go | 56 ++++++++++++++++++++++++++-- cmd/kosli/evaluateInput.go | 7 +++- cmd/kosli/evaluateInput_test.go | 65 +++++++++++++++++++++++++++++++++ cmd/kosli/evaluateTrail.go | 9 ++++- cmd/kosli/evaluateTrails.go | 9 ++++- 5 files changed, 140 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index c74e82f5f..464f456c6 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "strings" + "time" "github.com/kosli-dev/cli/internal/evaluate" "github.com/kosli-dev/cli/internal/output" @@ -15,6 +16,9 @@ import ( "github.com/spf13/cobra" ) +// policyFetchTimeout caps how long a remote --policy fetch can take. +var policyFetchTimeout = 3 * time.Second + type commonEvaluateOptions struct { flowName string policyFile string @@ -105,6 +109,52 @@ func fetchAndEnrichTrail(flowName, trailName string, attestations []string) (int return trailData, nil } +// loadPolicy reads a Rego policy from a local file path or, when ref starts +// with http:// or https://, fetches it over HTTP. Remote fetches are +// unauthenticated and uncached; callers are responsible for the integrity of +// the source. +func loadPolicy(ref string) ([]byte, error) { + if isRemotePolicyRef(ref) { + return fetchRemotePolicy(ref) + } + body, err := os.ReadFile(ref) + if err != nil { + return nil, fmt.Errorf("failed to read policy file: %w", err) + } + return body, nil +} + +func isRemotePolicyRef(ref string) bool { + return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") +} + +func fetchRemotePolicy(remoteURL string) ([]byte, error) { + if strings.HasPrefix(remoteURL, "http://") { + logger.Warn("fetching policy over plain HTTP from %s; prefer https://", remoteURL) + } + client := &http.Client{Timeout: policyFetchTimeout} + if global != nil && global.HttpProxy != "" { + proxyURL, err := url.Parse(global.HttpProxy) + if err != nil { + return nil, fmt.Errorf("failed to parse --http-proxy %q: %w", global.HttpProxy, err) + } + client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} + } + resp, err := client.Get(remoteURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch policy from %s: %w", remoteURL, err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read policy response from %s: %w", remoteURL, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("failed to fetch policy from %s: HTTP %d", remoteURL, resp.StatusCode) + } + return body, nil +} + func parseParams(raw string) (map[string]interface{}, error) { if raw == "" { return nil, nil @@ -128,10 +178,10 @@ func parseParams(raw string) (map[string]interface{}, error) { return params, nil } -func evaluateAndPrintResult(out io.Writer, policyFile string, input map[string]interface{}, outputFormat string, showInput bool, params map[string]interface{}, assertOnDeny bool) error { - policySource, err := os.ReadFile(policyFile) +func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]interface{}, outputFormat string, showInput bool, params map[string]interface{}, assertOnDeny bool) error { + policySource, err := loadPolicy(policyRef) if err != nil { - return fmt.Errorf("failed to read policy file: %w", err) + return err } result, err := evaluate.Evaluate(string(policySource), input, params) diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index f47a6eae0..cdf5bee71 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -69,6 +69,11 @@ kosli evaluate input \ --policy policy.rego \ --params @params.json +# evaluate using a policy fetched from a remote URL: +kosli evaluate input \ + --input-file trail-data.json \ + --policy https://policies.example.com/policy.rego + # evaluate as a decision point (print verdict, never fail the step): kosli evaluate input \ --input-file trail-data.json \ @@ -88,7 +93,7 @@ func newEvaluateInputCmd(out io.Writer) *cobra.Command { }, } - o.addFlags(cmd, "Path to a Rego policy file to evaluate against the input.") + o.addFlags(cmd, "Path or http(s):// URL of a Rego policy to evaluate against the input.") cmd.Flags().StringVarP(&o.inputFile, "input-file", "i", "", "[optional] Path to a JSON input file. Reads from stdin if omitted.") cmd.Flags().Lookup("flow").Hidden = true diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index 1ef8e8dad..72f20f61b 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "net/http" + "net/http/httptest" "strings" "testing" @@ -157,6 +160,68 @@ func TestLoadInputInvalidJSON(t *testing.T) { require.Contains(t, err.Error(), "failed to parse input") } +func TestLoadPolicyFromLocalFile(t *testing.T) { + body, err := loadPolicy("testdata/policies/allow-all.rego") + require.NoError(t, err) + require.Contains(t, string(body), "package policy") +} + +func TestLoadPolicyMissingLocalFile(t *testing.T) { + _, err := loadPolicy("testdata/policies/no-such-file.rego") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to read policy file") +} + +func TestLoadPolicyFromHTTPS(t *testing.T) { + const rego = "package policy\n\nallow = true\n" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/policy.rego", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, rego) + })) + defer server.Close() + + body, err := loadPolicy(server.URL + "/policy.rego") + require.NoError(t, err) + require.Equal(t, rego, string(body)) +} + +func TestLoadPolicyRemoteNon2xx(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := loadPolicy(server.URL + "/missing.rego") + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP 404") +} + +func TestLoadPolicyHonorsHTTPProxy(t *testing.T) { + const rego = "package policy\n\nallow = true\n" + + var sawProxyStyleRequest bool + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A request routed through an HTTP proxy carries an absolute URL on + // the request line, so r.URL.Host will be populated. + if r.URL.Host != "" { + sawProxyStyleRequest = true + } + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, rego) + })) + defer proxy.Close() + + prev := global + global = &GlobalOpts{HttpProxy: proxy.URL} + t.Cleanup(func() { global = prev }) + + body, err := loadPolicy("http://policies.example.invalid/policy.rego") + require.NoError(t, err) + require.Equal(t, rego, string(body)) + require.True(t, sawProxyStyleRequest, "expected proxy to receive an absolute-URL request") +} + func TestEvaluateInputCommandTestSuite(t *testing.T) { suite.Run(t, new(EvaluateInputCommandTestSuite)) } diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index fb404c665..c4123f75a 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -57,6 +57,13 @@ kosli evaluate trail yourTrailName \ --api-token yourAPIToken \ --org yourOrgName +# evaluate a trail using a policy fetched from a remote URL: +kosli evaluate trail yourTrailName \ + --policy https://policies.example.com/trail.rego \ + --flow yourFlowName \ + --api-token yourAPIToken \ + --org yourOrgName + # evaluate a trail as a decision point (print verdict, never fail the step): kosli evaluate trail yourTrailName \ --policy yourPolicyFile.rego \ @@ -89,7 +96,7 @@ func newEvaluateTrailCmd(out io.Writer) *cobra.Command { }, } - o.addFlags(cmd, "Path to a Rego policy file to evaluate against the trail.") + o.addFlags(cmd, "Path or http(s):// URL of a Rego policy to evaluate against the trail.") err := RequireFlags(cmd, []string{"flow", "policy"}) if err != nil { diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 6b80e7d2f..d79bbcefe 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -50,6 +50,13 @@ kosli evaluate trails yourTrailName1 yourTrailName2 \ --api-token yourAPIToken \ --org yourOrgName +# evaluate trails using a policy fetched from a remote URL: +kosli evaluate trails yourTrailName1 yourTrailName2 \ + --policy https://policies.example.com/trails.rego \ + --flow yourFlowName \ + --api-token yourAPIToken \ + --org yourOrgName + # evaluate trails as a decision point (print verdict, never fail the step): kosli evaluate trails yourTrailName1 yourTrailName2 \ --policy yourPolicyFile.rego \ @@ -82,7 +89,7 @@ func newEvaluateTrailsCmd(out io.Writer) *cobra.Command { }, } - o.addFlags(cmd, "Path to a Rego policy file to evaluate against the trails.") + o.addFlags(cmd, "Path or http(s):// URL of a Rego policy to evaluate against the trails.") err := RequireFlags(cmd, []string{"flow", "policy"}) if err != nil { From b6e7ba29e832e33e9d61d6efd00e07ac5f6b068a Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 8 May 2026 23:33:32 +0100 Subject: [PATCH 2/5] fix: adress security and timeout feedback --- cmd/kosli/evaluateHelpers.go | 42 +++++++++++++++++++++++---- cmd/kosli/evaluateInput_test.go | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 464f456c6..d425e3730 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -17,7 +17,12 @@ import ( ) // policyFetchTimeout caps how long a remote --policy fetch can take. -var policyFetchTimeout = 3 * time.Second +var policyFetchTimeout = 10 * time.Second + +// policyMaxBytes caps how much of a remote --policy response we read into +// memory. Real Rego policies are kilobytes; this guards against a malicious or +// misconfigured server streaming an unbounded body. 5 * 2^20 (1Mb) +const policyMaxBytes = 5 << 20 // 5 MiB type commonEvaluateOptions struct { flowName string @@ -132,7 +137,12 @@ func fetchRemotePolicy(remoteURL string) ([]byte, error) { if strings.HasPrefix(remoteURL, "http://") { logger.Warn("fetching policy over plain HTTP from %s; prefer https://", remoteURL) } - client := &http.Client{Timeout: policyFetchTimeout} + + client := &http.Client{ + Timeout: policyFetchTimeout, + CheckRedirect: sameHostRedirectPolicy, + } + if global != nil && global.HttpProxy != "" { proxyURL, err := url.Parse(global.HttpProxy) if err != nil { @@ -140,21 +150,43 @@ func fetchRemotePolicy(remoteURL string) ([]byte, error) { } client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} } + resp, err := client.Get(remoteURL) if err != nil { return nil, fmt.Errorf("failed to fetch policy from %s: %w", remoteURL, err) } defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("failed to fetch policy from %s: HTTP %d", remoteURL, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, policyMaxBytes+1)) if err != nil { return nil, fmt.Errorf("failed to read policy response from %s: %w", remoteURL, err) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("failed to fetch policy from %s: HTTP %d", remoteURL, resp.StatusCode) + if int64(len(body)) > policyMaxBytes { + return nil, fmt.Errorf("policy at %s exceeds %d-byte limit", remoteURL, policyMaxBytes) } + return body, nil } +// sameHostRedirectPolicy allows redirects only when the target host matches +// the most recent request's host. This blocks an SSRF vector where a trusted +// remote redirects the CLI to an internal address. +func sameHostRedirectPolicy(req *http.Request, via []*http.Request) error { + if len(via) == 0 { + return nil + } + if len(via) >= 5 { + return fmt.Errorf("stopped after %d redirects", len(via)) + } + if req.URL.Host != via[len(via)-1].URL.Host { + return fmt.Errorf("cross-host redirect to %s blocked", req.URL.Host) + } + return nil +} + func parseParams(raw string) (map[string]interface{}, error) { if raw == "" { return nil, nil diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index 72f20f61b..95a353e1c 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -197,6 +197,57 @@ func TestLoadPolicyRemoteNon2xx(t *testing.T) { require.Contains(t, err.Error(), "HTTP 404") } +func TestLoadPolicyDoesNotReadNon2xxBody(t *testing.T) { + var bodyServed bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, "huge error page") + bodyServed = true + })) + defer server.Close() + + _, err := loadPolicy(server.URL + "/policy.rego") + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP 500") + // We may or may not have raced the handler, but the error must not + // contain the body text — the status check happens before the read. + require.NotContains(t, err.Error(), "huge error page") + _ = bodyServed +} + +func TestLoadPolicyRejectsOversizedBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + // Stream just over the 5 MiB cap. + chunk := make([]byte, 1<<20) + for i := 0; i < 6; i++ { + _, _ = w.Write(chunk) + } + })) + defer server.Close() + + _, err := loadPolicy(server.URL + "/policy.rego") + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds") +} + +func TestLoadPolicyBlocksCrossHostRedirect(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "package policy\nallow = true\n") + })) + defer target.Close() + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/policy.rego", http.StatusFound) + })) + defer redirector.Close() + + _, err := loadPolicy(redirector.URL + "/policy.rego") + require.Error(t, err) + require.Contains(t, err.Error(), "cross-host redirect") +} + func TestLoadPolicyHonorsHTTPProxy(t *testing.T) { const rego = "package policy\n\nallow = true\n" From 90cffcb00188cf13f8a90918078b5bdf6a40464c Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 8 May 2026 23:49:25 +0100 Subject: [PATCH 3/5] fix: size typo in comment --- cmd/kosli/evaluateHelpers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index d425e3730..ffd7bfd2b 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -21,7 +21,7 @@ var policyFetchTimeout = 10 * time.Second // policyMaxBytes caps how much of a remote --policy response we read into // memory. Real Rego policies are kilobytes; this guards against a malicious or -// misconfigured server streaming an unbounded body. 5 * 2^20 (1Mb) +// misconfigured server streaming an unbounded body. 5 * 2^20 (5*1MiB) const policyMaxBytes = 5 << 20 // 5 MiB type commonEvaluateOptions struct { From a526de21dcb56f3f6686341e553265e3cea4c84e Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 8 May 2026 23:54:01 +0100 Subject: [PATCH 4/5] chore: add a note about inheriting TLS/dial settings --- cmd/kosli/evaluateHelpers.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index ffd7bfd2b..9e844c1c6 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -148,6 +148,10 @@ func fetchRemotePolicy(remoteURL string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to parse --http-proxy %q: %w", global.HttpProxy, err) } + // This builds a fresh http.Transport with Go defaults. If the + // codebase later adopts a shared transport with custom TLS/dial + // settings, route policy fetches through it (or clone it) so they + // inherit those settings. client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} } From 189d66001b1d1b9cbe1d2599a8a283bdccf0e62a Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 9 May 2026 00:04:33 +0100 Subject: [PATCH 5/5] chore: rename argument for consistency and remove dead code --- cmd/kosli/evaluateHelpers.go | 7 ++----- cmd/kosli/evaluateInput.go | 2 +- cmd/kosli/evaluateTrail.go | 2 +- cmd/kosli/evaluateTrails.go | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 9e844c1c6..29991d751 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -26,7 +26,7 @@ const policyMaxBytes = 5 << 20 // 5 MiB type commonEvaluateOptions struct { flowName string - policyFile string + policyRef string output string showInput bool attestations []string @@ -37,7 +37,7 @@ type commonEvaluateOptions struct { func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) { cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) - cmd.Flags().StringVarP(&o.policyFile, "policy", "p", "", policyDesc) + cmd.Flags().StringVarP(&o.policyRef, "policy", "p", "", policyDesc) cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) cmd.Flags().BoolVar(&o.showInput, "show-input", false, "[optional] Include the policy input data in the output.") cmd.Flags().StringSliceVar(&o.attestations, "attestations", nil, "[optional] Limit which attestations are included. Plain name for trail-level, dot-qualified (artifact.name) for artifact-level.") @@ -179,9 +179,6 @@ func fetchRemotePolicy(remoteURL string) ([]byte, error) { // the most recent request's host. This blocks an SSRF vector where a trusted // remote redirects the CLI to an internal address. func sameHostRedirectPolicy(req *http.Request, via []*http.Request) error { - if len(via) == 0 { - return nil - } if len(via) >= 5 { return fmt.Errorf("stopped after %d redirects", len(via)) } diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index cdf5bee71..61d561c3b 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -128,7 +128,7 @@ func (o *evaluateInputOptions) run(out io.Writer, in io.Reader) error { return err } - return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) } func loadInputFromFile(filePath string) (result map[string]interface{}, err error) { diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index c4123f75a..f967f2ce9 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -121,5 +121,5 @@ func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { "trail": trailData, } - return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) } diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index d79bbcefe..801a1850e 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -118,5 +118,5 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { "trails": trails, } - return evaluateAndPrintResult(out, o.policyFile, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) }