Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions cmd/kosli/evaluateHelpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,25 @@ import (
"net/url"
"os"
"strings"
"time"

"github.com/kosli-dev/cli/internal/evaluate"
"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

// policyFetchTimeout caps how long a remote --policy fetch can take.
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 (5*1MiB)
const policyMaxBytes = 5 << 20 // 5 MiB
Comment thread
mbevc1 marked this conversation as resolved.
Comment thread
mbevc1 marked this conversation as resolved.
Comment thread
mbevc1 marked this conversation as resolved.

type commonEvaluateOptions struct {
flowName string
policyFile string
policyRef string
output string
showInput bool
attestations []string
Expand All @@ -28,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.")
Expand Down Expand Up @@ -105,6 +114,80 @@ 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,
CheckRedirect: sameHostRedirectPolicy,
}

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)
}
// 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)}
Comment thread
mbevc1 marked this conversation as resolved.
}

resp, err := client.Get(remoteURL)
Comment thread
mbevc1 marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("failed to fetch policy from %s: %w", remoteURL, err)
}
defer func() { _ = resp.Body.Close() }()
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 int64(len(body)) > policyMaxBytes {
return nil, fmt.Errorf("policy at %s exceeds %d-byte limit", remoteURL, policyMaxBytes)
}
Comment thread
mbevc1 marked this conversation as resolved.

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) >= 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
Expand All @@ -128,10 +211,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)
Expand Down
9 changes: 7 additions & 2 deletions cmd/kosli/evaluateInput.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand Down Expand Up @@ -123,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) {
Expand Down
116 changes: 116 additions & 0 deletions cmd/kosli/evaluateInput_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package main

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

Expand Down Expand Up @@ -157,6 +160,119 @@ 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 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"

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))
}
11 changes: 9 additions & 2 deletions cmd/kosli/evaluateTrail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 {
Expand All @@ -114,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())
}
11 changes: 9 additions & 2 deletions cmd/kosli/evaluateTrails.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -111,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())
}
Loading