diff --git a/pkg/policies/engine/rego/boilerplate.go b/pkg/policies/engine/rego/boilerplate.go index 2e72933fc..7d31335e7 100644 --- a/pkg/policies/engine/rego/boilerplate.go +++ b/pkg/policies/engine/rego/boilerplate.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-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. @@ -18,6 +18,7 @@ package rego import ( "bytes" _ "embed" + "errors" "fmt" "strings" "text/template" @@ -48,6 +49,22 @@ type boilerplateData struct { NeedsDefaultViolations bool } +// sanitizeParseError reduces a Rego parse error to the code and location of its first +// problem, since OPA parse errors can quote the policy source. +func sanitizeParseError(err error) error { + var astErrs ast.Errors + if !errors.As(err, &astErrs) || len(astErrs) == 0 { + return errors.New("invalid policy") + } + + first := astErrs[0] + if first.Location == nil { + return errors.New(first.Code) + } + + return fmt.Errorf("%s at line %d, column %d", first.Code, first.Location.Row, first.Location.Col) +} + // InjectBoilerplate automatically injects common policy boilerplate if it doesn't exist. // This allows users to write simplified policies with only the violations rules. // Requirements: Policy must have package declaration and import rego.v1 @@ -65,7 +82,7 @@ func InjectBoilerplate(policySource []byte, policyName string) ([]byte, error) { // Parse the policy module, err := ast.ParseModule(policyName, originalPolicy) if err != nil { - return nil, fmt.Errorf("failed to parse policy (must have 'package' and 'import rego.v1'): %w", err) + return nil, fmt.Errorf("failed to parse policy (must have 'package' and 'import rego.v1'): %w", sanitizeParseError(err)) } // Detect which rules already exist using AST diff --git a/pkg/policies/engine/rego/boilerplate_test.go b/pkg/policies/engine/rego/boilerplate_test.go index 8f57f20c7..7715b67b5 100644 --- a/pkg/policies/engine/rego/boilerplate_test.go +++ b/pkg/policies/engine/rego/boilerplate_test.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-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. @@ -91,6 +91,37 @@ func TestInjectBoilerplate(t *testing.T) { } } +func TestInjectBoilerplateParseErrorOmitsSource(t *testing.T) { + const marker = "unique_marker_value_42" + + testCases := []struct { + name string + input string + wantErr string + }{ + { + name: "non rego content", + input: "KEY=" + marker + "\n", + wantErr: "line 1, column 1", + }, + { + name: "illegal string literal", + input: "package main\n\nimport rego.v1\n\nx := \"" + marker + "\n", + wantErr: "line 5", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := InjectBoilerplate([]byte(tc.input), "test-policy") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse policy") + assert.Contains(t, err.Error(), tc.wantErr) + assert.NotContains(t, err.Error(), marker) + }) + } +} + // matchesOutput compares result against expected output file func matchesOutput(t *testing.T, result []byte, outputName string) { t.Helper() diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index f923e4fba..df4b6aa08 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -148,7 +148,7 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { - return nil, fmt.Errorf("failed to parse rego policy: %w", err) + return nil, fmt.Errorf("failed to parse rego policy: %w", sanitizeParseError(err)) } // Decode input as json @@ -386,7 +386,7 @@ func (r *Engine) MatchesParameters(ctx context.Context, policy *engine.Policy, e policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { - return false, fmt.Errorf("failed to parse rego policy: %w", err) + return false, fmt.Errorf("failed to parse rego policy: %w", sanitizeParseError(err)) } // Create input with policy and expected parameters @@ -422,7 +422,7 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v policyString := string(policy.Source) parsedModule, err := ast.ParseModule(policy.Name, policyString) if err != nil { - return false, fmt.Errorf("failed to parse rego policy: %w", err) + return false, fmt.Errorf("failed to parse rego policy: %w", sanitizeParseError(err)) } // Create input expected parameters and policy violations diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 1cddaa898..331cdeece 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -31,6 +31,53 @@ import ( "github.com/stretchr/testify/require" ) +func TestRego_ParseErrorOmitsSource(t *testing.T) { + const marker = "unique_marker_value_42" + + r := NewEngine() + policy := &engine.Policy{ + Name: "invalid", + Source: []byte("package main\n\nx := \"" + marker + "\n"), + } + + testCases := []struct { + name string + run func() error + }{ + { + name: "verify", + run: func() error { + _, err := r.Verify(context.TODO(), policy, []byte("{}"), nil) + return err + }, + }, + { + name: "matches parameters", + run: func() error { + _, err := r.MatchesParameters(context.TODO(), policy, nil, nil) + return err + }, + }, + { + name: "matches evaluation", + run: func() error { + _, err := r.MatchesEvaluation(context.TODO(), policy, nil, nil) + return err + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.run() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse rego policy") + assert.Contains(t, err.Error(), "line 3") + assert.NotContains(t, err.Error(), marker) + }) + } +} + func TestRego_VerifyWithValidPolicy(t *testing.T) { regoContent, err := os.ReadFile("testfiles/check_qa.rego") require.NoError(t, err) diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 403a973a5..eac8839aa 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -20,6 +20,7 @@ import ( "encoding/base64" "errors" "fmt" + "io/fs" "maps" "net/url" "path/filepath" @@ -396,15 +397,8 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme return nil, nil } - var basePath string - // if it's a file://, let's calculate the base path for loading referenced policies, from the loader ref - if ref != nil { - // calculate the file path if it's a file:// reference - basePath, _ = ensureScheme(attachment.GetRef(), fileScheme) - } - // load the policy scripts (rego) - scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, basePath) + scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, policyLocation(attachment.GetRef())) if err != nil { return nil, NewPolicyError(err) } @@ -1111,121 +1105,161 @@ func decodeIfBase64Wasm(content []byte) []byte { return content } -// isURLPath checks if a path is an HTTP or HTTPS URL -func isURLPath(path string) bool { - scheme, _ := RefParts(path) - return scheme == httpScheme || scheme == httpsScheme +var errInvalidScriptRef = errors.New("invalid policy script reference") + +// policyLocation returns the location of the policy spec that a contract or policy group +// attachment references, used to resolve the relative script references inside that spec. +// It is only known for file:// and https:// attachments, and empty otherwise. +func policyLocation(attachmentRef string) string { + switch scheme, _ := RefParts(attachmentRef); scheme { + case fileScheme, httpsScheme: + location, _ := ExtractDigest(attachmentRef) + return location + default: + return "" + } } -// resolveReference resolves a reference (which may be relative) against a base path -// If ref is absolute (has a scheme), it returns ref as-is -// If basePath is a file:// path, it uses filepath.Join for resolution -// If basePath is an http(s):// URL, it uses URL parsing for resolution -func resolveReference(ref, basePath string) (string, error) { - // Check if ref is already absolute (has a scheme) - refScheme, _ := RefParts(ref) - if refScheme != "" { - // Already absolute, return as-is +// resolveScriptRef resolves a script reference declared inside a policy spec (its ref or +// path fields) against basePath, the location of that policy spec. +// It does not apply to the reference that loads the policy spec itself. +// Absolute references must use https://. A relative reference is resolved against the +// spec URL when the spec is served over https, or confined to the spec directory when the +// spec is a local file (file:// or a plain path). Any other relative reference is rejected. +func resolveScriptRef(ref, basePath string) (string, error) { + resolved, err := resolveScriptLocation(ref, basePath) + if err != nil { + return "", fmt.Errorf("%w %q: %w", errInvalidScriptRef, ref, err) + } + + return resolved, nil +} + +func resolveScriptLocation(ref, basePath string) (string, error) { + if ref == "" { + return "", errors.New("empty reference") + } + + if scheme, _ := RefParts(ref); scheme != "" { + if _, err := parseHTTPSURL(ref); err != nil { + return "", err + } + return ref, nil } - // Get the scheme of basePath baseScheme, baseLoc := RefParts(basePath) - - switch baseScheme { - case fileScheme: - // File path resolution - return filepath.Join(filepath.Dir(baseLoc), ref), nil - case httpScheme, httpsScheme: - // HTTP(S) URL resolution - baseURL, err := url.Parse(basePath) + switch { + case baseScheme == httpsScheme: + baseURL, err := parseHTTPSURL(basePath) if err != nil { - return "", fmt.Errorf("invalid base URL %q: %w", basePath, err) + return "", fmt.Errorf("invalid base URL: %w", err) } - // Parse the reference relative to the base URL - resolvedURL, err := baseURL.Parse(ref) + resolved, err := baseURL.Parse(ref) if err != nil { - return "", fmt.Errorf("failed to resolve reference %q against base %q: %w", ref, basePath, err) + return "", err + } + + if err := ensureHTTPSURL(resolved); err != nil { + return "", err + } + + return resolved.String(), nil + case baseScheme == fileScheme || (baseScheme == "" && baseLoc != ""): + if !filepath.IsLocal(ref) { + return "", errors.New("must be a relative path within the policy directory") } - return resolvedURL.String(), nil - case "": - // No scheme in basePath, treat as file path - return filepath.Join(filepath.Dir(basePath), ref), nil + return confineToDir(filepath.Dir(baseLoc), ref) default: - return "", fmt.Errorf("unsupported base path scheme: %s", baseScheme) + return "", errors.New("relative references are only supported for policies loaded from a file or an https URL") + } +} + +// confineToDir joins dir and the local path ref, and checks that the result, after +// following symlinks, is still inside dir. A path that does not exist is returned +// as is, so that loading it reports that it was not found. +func confineToDir(dir, ref string) (string, error) { + candidate := filepath.Join(dir, ref) + + target, err := filepath.EvalSymlinks(candidate) + if errors.Is(err, fs.ErrNotExist) { + return candidate, nil + } else if err != nil { + return "", err + } + + realDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", err + } + + rel, err := filepath.Rel(realDir, target) + if err != nil || !filepath.IsLocal(rel) { + return "", errors.New("must be a path within the policy directory") + } + + return target, nil +} + +// parseHTTPSURL parses raw and checks that it is an absolute https:// URL with a host +func parseHTTPSURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, err } + + return u, ensureHTTPSURL(u) +} + +func ensureHTTPSURL(u *url.URL) error { + if u.Scheme != httpsScheme || u.Host == "" { + return fmt.Errorf("only %s:// URLs are supported", httpsScheme) + } + + return nil +} + +// loadScriptContent loads a policy script from a reference declared in the policy spec located at basePath +func loadScriptContent(ref, basePath string) ([]byte, error) { + scriptPath, err := resolveScriptRef(ref, basePath) + if err != nil { + return nil, fmt.Errorf("resolving policy reference: %w", err) + } + + content, err := blob.LoadFileOrURL(scriptPath) + if err != nil { + return nil, fmt.Errorf("loading policy content: %w", err) + } + + // Decode base64 if this is a base64-encoded WASM policy + return decodeIfBase64Wasm(content), nil } func loadPolicyScript(spec *v1.PolicySpecV2, basePath string) ([]byte, error) { - var content []byte - var err error switch source := spec.GetSource().(type) { case *v1.PolicySpecV2_Embedded: - content = []byte(source.Embedded) + return decodeIfBase64Wasm([]byte(source.Embedded)), nil case *v1.PolicySpecV2_Ref: - // New ref field with relative URL resolution - scriptPath, err := resolveReference(source.Ref, basePath) - if err != nil { - return nil, fmt.Errorf("resolving policy reference: %w", err) - } - content, err = blob.LoadFileOrURL(scriptPath) - if err != nil { - return nil, fmt.Errorf("loading policy content: %w", err) - } + return loadScriptContent(source.Ref, basePath) case *v1.PolicySpecV2_Path: // Deprecated: kept for backward compatibility - var scriptPath string - // If the path is a URL, use it directly. Otherwise, resolve it relative to basePath - if isURLPath(source.Path) { - scriptPath = source.Path - } else { - // path relative to policy folder - scriptPath = filepath.Join(filepath.Dir(basePath), source.Path) - } - content, err = blob.LoadFileOrURL(scriptPath) - if err != nil { - return nil, fmt.Errorf("loading policy content: %w", err) - } + return loadScriptContent(source.Path, basePath) default: return nil, fmt.Errorf("policy spec is empty") } - - // Decode base64 if this is a base64-encoded WASM policy - content = decodeIfBase64Wasm(content) - - return content, nil } func loadLegacyPolicyScript(spec *v1.PolicySpec, basePath string) ([]byte, error) { - // legacy policies - var content []byte - var err error switch source := spec.GetSource().(type) { case *v1.PolicySpec_Embedded: - content = []byte(source.Embedded) + return decodeIfBase64Wasm([]byte(source.Embedded)), nil case *v1.PolicySpec_Path: - var scriptPath string - // If the path is a URL, use it directly. Otherwise, resolve it relative to basePath - if isURLPath(source.Path) { - scriptPath = source.Path - } else { - // path relative to policy folder - scriptPath = filepath.Join(filepath.Dir(basePath), source.Path) - } - content, err = blob.LoadFileOrURL(scriptPath) - if err != nil { - return nil, fmt.Errorf("loading policy content: %w", err) - } + return loadScriptContent(source.Path, basePath) default: return nil, fmt.Errorf("policy spec is empty") } - - // Decode base64 if this is a base64-encoded WASM policy - content = decodeIfBase64Wasm(content) - - return content, nil } func LogPolicyEvaluations(evaluations []*v12.PolicyEvaluation, logger *zerolog.Logger) { diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index a9e825a2b..cc7eecc7d 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1758,53 +1758,234 @@ func loadStatement(file string, s *suite.Suite) *intoto.Statement { return &statement } -func (s *testSuite) TestIsURLPath() { +// Policies are loaded in two steps: +// 1. a contract or policy group attachment references a policy spec (policy.yaml) +// through chainloop://, file:// or https://. These tests do not change that step. +// 2. the policy spec references its Rego or WASM scripts through spec.policies[].ref, +// spec.policies[].path or the legacy spec.path. The tests below cover this step. +// +// The location of the policy spec from step 1 is the base to resolve relative script +// references from step 2. + +// TestPolicyLocation covers how the location of a policy spec is derived from the +// attachment ref that loaded it (step 1). +func (s *testSuite) TestPolicyLocation() { + const ( + remotePolicy = "https://example.com/policy.yaml" + localPolicy = "file://testdata/policy.yaml" + ) + cases := []struct { - name string - path string - expected bool + name string + attachmentRef string + want string + }{ + {name: "policy attached from an https URL", attachmentRef: remotePolicy, want: remotePolicy}, + {name: "policy attached from an https URL with digest", attachmentRef: remotePolicy + "@sha256:1234", want: remotePolicy}, + {name: "policy attached from a local file", attachmentRef: localPolicy, want: localPolicy}, + {name: "policy attached from a local file with digest", attachmentRef: localPolicy + "@sha256:1234", want: localPolicy}, + {name: "policy attached from an http URL has no location", attachmentRef: "http://example.com/policy.yaml"}, + {name: "policy attached from a chainloop provider has no location", attachmentRef: "chainloop://provider/policy"}, + {name: "policy attached by name has no location", attachmentRef: "policy"}, + {name: "policy embedded in the attachment has no location", attachmentRef: ""}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + s.Equal(tc.want, policyLocation(tc.attachmentRef)) + }) + } +} + +// TestResolveScriptRef covers how a script reference inside a policy spec (step 2) +// is resolved, given the location of that policy spec. +func (s *testSuite) TestResolveScriptRef() { + const ( + remotePolicy = "https://example.com/policies/policy.yaml" + localPolicy = "file://testdata/policy.yaml" + remoteScript = "https://example.com/p.rego" + script = "p.rego" + ) + + cases := []struct { + name string + scriptRef string + policyLocation string + want string + wantErr bool + }{ + // https:// script refs are allowed from any policy spec + {name: "https script in a remote policy", scriptRef: "https://other.example.com/p.rego", policyLocation: remotePolicy, want: "https://other.example.com/p.rego"}, + {name: "https script in a local policy", scriptRef: remoteScript, policyLocation: localPolicy, want: remoteScript}, + {name: "https script in an embedded policy", scriptRef: remoteScript, want: remoteScript}, + + // relative script refs in a remote policy resolve against the URL of the policy + {name: "relative script in a remote policy", scriptRef: "rego/p.rego", policyLocation: remotePolicy, want: "https://example.com/policies/rego/p.rego"}, + {name: "parent script in a remote policy stays on the URL", scriptRef: "../p.rego", policyLocation: remotePolicy, want: remoteScript}, + {name: "rejects relative script in an http policy", scriptRef: script, policyLocation: "http://example.com/policy.yaml", wantErr: true}, + {name: "rejects opaque https script in a remote policy", scriptRef: "https:p.rego", policyLocation: remotePolicy, wantErr: true}, + + // relative script refs in a local policy stay in the directory of the policy + {name: "relative script in a local policy", scriptRef: script, policyLocation: localPolicy, want: filepath.Join("testdata", script)}, + {name: "relative script in a local policy given as a plain path", scriptRef: "sub/p.rego", policyLocation: "testdata/policy.yaml", want: filepath.Join("testdata", "sub", "p.rego")}, + {name: "rejects parent script in a local policy", scriptRef: "../p.rego", policyLocation: localPolicy, wantErr: true}, + {name: "rejects nested parent script in a local policy", scriptRef: "sub/../../p.rego", policyLocation: localPolicy, wantErr: true}, + {name: "rejects absolute script path in a local policy", scriptRef: "/tmp/p.rego", policyLocation: localPolicy, wantErr: true}, + + // relative script refs need a policy location + {name: "rejects relative script in an embedded policy", scriptRef: script, wantErr: true}, + {name: "rejects relative script in a chainloop provider policy", scriptRef: script, policyLocation: "chainloop://provider/policy", wantErr: true}, + + // other script schemes are rejected from any policy spec + {name: "rejects empty script ref", scriptRef: "", policyLocation: localPolicy, wantErr: true}, + {name: "rejects env script in a remote policy", scriptRef: "env://SOME_VAR", policyLocation: remotePolicy, wantErr: true}, + {name: "rejects env script in a local policy", scriptRef: "env://SOME_VAR", policyLocation: localPolicy, wantErr: true}, + {name: "rejects file script in a remote policy", scriptRef: "file:///tmp/p.rego", policyLocation: remotePolicy, wantErr: true}, + {name: "rejects file script in a local policy", scriptRef: "file://p.rego", policyLocation: localPolicy, wantErr: true}, + {name: "rejects http script", scriptRef: "http://example.com/p.rego", policyLocation: remotePolicy, wantErr: true}, + {name: "rejects chainloop script", scriptRef: "chainloop://provider/p", policyLocation: remotePolicy, wantErr: true}, + {name: "rejects https script without host", scriptRef: "https:///p.rego", policyLocation: remotePolicy, wantErr: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + got, err := resolveScriptRef(tc.scriptRef, tc.policyLocation) + if tc.wantErr { + s.ErrorIs(err, errInvalidScriptRef) + return + } + + s.Require().NoError(err) + s.Equal(tc.want, got) + }) + } +} + +// TestResolveScriptRefSymlinks covers symlinks in the directory of a local policy spec (step 2) +func (s *testSuite) TestResolveScriptRefSymlinks() { + root := s.T().TempDir() + policyDir := filepath.Join(root, "policies") + s.Require().NoError(os.MkdirAll(filepath.Join(policyDir, "sub"), 0o755)) + s.Require().NoError(os.WriteFile(filepath.Join(root, "outside.rego"), []byte("package main"), 0o600)) + s.Require().NoError(os.WriteFile(filepath.Join(policyDir, "sub", "inside.rego"), []byte("package main"), 0o600)) + s.Require().NoError(os.Symlink(filepath.Join(root, "outside.rego"), filepath.Join(policyDir, "outside_link.rego"))) + s.Require().NoError(os.Symlink(root, filepath.Join(policyDir, "outside_dir"))) + s.Require().NoError(os.Symlink(filepath.Join("sub", "inside.rego"), filepath.Join(policyDir, "inside_link.rego"))) + + location := "file://" + filepath.Join(policyDir, "policy.yaml") + + cases := []struct { + name string + scriptRef string + wantErr bool + }{ + {name: "script inside the policy directory", scriptRef: "sub/inside.rego"}, + {name: "symlink to a script inside the policy directory", scriptRef: "inside_link.rego"}, + {name: "rejects symlink to a script outside the policy directory", scriptRef: "outside_link.rego", wantErr: true}, + {name: "rejects script under a symlinked directory outside the policy directory", scriptRef: "outside_dir/outside.rego", wantErr: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + got, err := resolveScriptRef(tc.scriptRef, location) + if tc.wantErr { + s.ErrorIs(err, errInvalidScriptRef) + return + } + + s.Require().NoError(err) + _, err = os.Stat(got) + s.NoError(err) + }) + } +} + +// TestLoadPolicyScriptsFromSpecRejectsInvalidRefs covers each script reference field of a +// policy spec (step 2): spec.policies[].ref, spec.policies[].path and the legacy spec.path. +func (s *testSuite) TestLoadPolicyScriptsFromSpecRejectsInvalidRefs() { + const ( + envVar = "CHAINLOOP_TEST_SCRIPT_REF" + marker = "unique_marker_value_42" + ) + + s.T().Setenv(envVar, marker) + + v2 := func(src *v12.PolicySpecV2) *v12.Policy { + return &v12.Policy{ + Metadata: &v12.Metadata{Name: "test"}, + Spec: &v12.PolicySpec{Policies: []*v12.PolicySpecV2{src}}, + } + } + + cases := []struct { + name string + policy *v12.Policy + policyLocation string }{ { - name: "http URL", - path: "http://example.com/policy.rego", - expected: true, - }, - { - name: "https URL", - path: "https://example.com/policy.rego", - expected: true, - }, - { - name: "relative file path", - path: "policy.rego", - expected: false, + name: "env script ref in an embedded policy", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Ref{Ref: "env://" + envVar}}), }, { - name: "absolute file path", - path: "/absolute/path/policy.rego", - expected: false, + name: "env script ref in a remote policy", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Ref{Ref: "env://" + envVar}}), + policyLocation: "https://example.com/policy.yaml", }, { - name: "file scheme", - path: "file:///path/to/policy.rego", - expected: false, + name: "relative script path in an embedded policy", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Path{Path: "testdata/workflow.rego"}}), }, { - name: "chainloop scheme", - path: "chainloop://provider/policy", - expected: false, + name: "script path outside the directory of a local policy", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Path{Path: "../policies/testdata/workflow.rego"}}), + policyLocation: "file://testdata/policy.yaml", }, { - name: "empty path", - path: "", - expected: false, + name: "legacy script path outside the directory of a local policy", + policy: &v12.Policy{ + Metadata: &v12.Metadata{Name: "test"}, + Spec: &v12.PolicySpec{Source: &v12.PolicySpec_Path{Path: "../policies/testdata/workflow.rego"}}, + }, + policyLocation: "file://testdata/policy.yaml", }, } for _, tc := range cases { s.Run(tc.name, func() { - result := isURLPath(tc.path) - s.Equal(tc.expected, result) + _, err := LoadPolicyScriptsFromSpec(tc.policy, v12.CraftingSchema_Material_MATERIAL_TYPE_UNSPECIFIED, tc.policyLocation) + s.ErrorIs(err, errInvalidScriptRef) + s.NotContains(err.Error(), marker) + }) + } +} + +// TestVerifyStatementScriptRefs covers both steps together: the attachment loads a local +// policy spec (step 1), and the script reference inside it is checked (step 2). +func (s *testSuite) TestVerifyStatementScriptRefs() { + cases := []struct { + name string + attachmentRef string + wantErr bool + }{ + {name: "local policy with a script in its directory", attachmentRef: "file://testdata/workflow.yaml"}, + {name: "rejects local policy with a script outside its directory", attachmentRef: "file://testdata/policy_script_outside_dir.yaml", wantErr: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + policies := &v12.Policies{ + Attestation: []*v12.PolicyAttachment{{Policy: &v12.PolicyAttachment_Ref{Ref: tc.attachmentRef}}}, + } + verifier := NewPolicyVerifier(policies, nil, &s.logger) + statement := loadStatement("testdata/statement.json", &s.Suite) + + _, err := verifier.VerifyStatement(context.TODO(), statement) + if tc.wantErr { + s.ErrorIs(err, errInvalidScriptRef) + return + } + + s.NoError(err) }) } } diff --git a/pkg/policies/testdata/policy_script_outside_dir.yaml b/pkg/policies/testdata/policy_script_outside_dir.yaml new file mode 100644 index 000000000..4f40da34f --- /dev/null +++ b/pkg/policies/testdata/policy_script_outside_dir.yaml @@ -0,0 +1,8 @@ +apiVersion: chainloop.dev/v1 +kind: Policy +metadata: + name: script-outside-dir +spec: + policies: + - kind: ATTESTATION + ref: ../testdata/workflow.rego