From 051ea5d4af9382d65f3db0b5b80fe24dad269a11 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 9 Oct 2026 13:02:00 +0200 Subject: [PATCH 1/4] fix(policies): restrict policy script references to https and local paths Policy script references (ref and path) are now resolved through a single resolver that only accepts absolute https:// URLs. Relative references are resolved against the policy spec URL when the spec is served over https, and confined to the spec directory when the spec is a local file. Other schemes, absolute paths and paths that leave the policy directory are rejected. Rego parse errors returned while loading a policy now report only the error code and location instead of quoting the policy source. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 5c767ce4-bc27-47f4-8a11-def62c79205b --- pkg/policies/engine/rego/boilerplate.go | 26 ++- pkg/policies/engine/rego/boilerplate_test.go | 33 +++- pkg/policies/policies.go | 171 ++++++++++--------- pkg/policies/policies_test.go | 119 +++++++++---- 4 files changed, 231 insertions(+), 118 deletions(-) diff --git a/pkg/policies/engine/rego/boilerplate.go b/pkg/policies/engine/rego/boilerplate.go index 2e72933fc..3365aca31 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,27 @@ type boilerplateData struct { NeedsDefaultViolations bool } +// parseModule parses a Rego module. On failure, the error holds only the code and +// location of the first problem, since OPA parse errors can quote the policy source. +func parseModule(name, source string) (*ast.Module, error) { + module, err := ast.ParseModule(name, source) + if err == nil { + return module, nil + } + + var astErrs ast.Errors + if !errors.As(err, &astErrs) || len(astErrs) == 0 { + return nil, errors.New("invalid policy") + } + + first := astErrs[0] + if first.Location == nil { + return nil, errors.New(first.Code) + } + + return nil, 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 @@ -63,7 +85,7 @@ func InjectBoilerplate(policySource []byte, policyName string) ([]byte, error) { originalPolicy := string(policySource) // Parse the policy - module, err := ast.ParseModule(policyName, originalPolicy) + module, err := parseModule(policyName, originalPolicy) if err != nil { return nil, fmt.Errorf("failed to parse policy (must have 'package' and 'import rego.v1'): %w", err) } 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/policies.go b/pkg/policies/policies.go index 403a973a5..0c4826e40 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -396,11 +396,12 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme return nil, nil } + // Relative script references are resolved against the location of the policy spec, + // which is only known for file:// and https:// specs 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) + switch scheme, _ := RefParts(attachment.GetRef()); scheme { + case fileScheme, httpsScheme: + basePath, _ = ExtractDigest(attachment.GetRef()) } // load the policy scripts (rego) @@ -1111,121 +1112,121 @@ 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") + +// resolveScriptRef resolves a policy script reference against basePath, the location +// of the policy spec that declares it. +// 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 } -// 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 +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 } - return resolvedURL.String(), nil - case "": - // No scheme in basePath, treat as file path - return filepath.Join(filepath.Dir(basePath), ref), nil + 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 filepath.Join(filepath.Dir(baseLoc), ref), nil 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") + } +} + +// 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..b0b657be4 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1758,53 +1758,112 @@ func loadStatement(file string, s *suite.Suite) *intoto.Statement { return &statement } -func (s *testSuite) TestIsURLPath() { +func (s *testSuite) TestResolveScriptRef() { + const ( + remoteBase = "https://example.com/policies/policy.yaml" + localBase = "file://testdata/policy.yaml" + remoteScript = "https://example.com/p.rego" + script = "p.rego" + ) + cases := []struct { name string - path string - expected bool + ref string + basePath string + want string + wantErr bool + }{ + {name: "absolute https from remote spec", ref: "https://other.example.com/p.rego", basePath: remoteBase, want: "https://other.example.com/p.rego"}, + {name: "absolute https from local spec", ref: remoteScript, basePath: localBase, want: remoteScript}, + {name: "absolute https from embedded spec", ref: remoteScript, want: remoteScript}, + {name: "relative ref from remote spec", ref: "rego/p.rego", basePath: remoteBase, want: "https://example.com/policies/rego/p.rego"}, + {name: "parent ref from remote spec stays on the URL", ref: "../p.rego", basePath: remoteBase, want: remoteScript}, + {name: "relative ref from local spec", ref: script, basePath: localBase, want: filepath.Join("testdata", script)}, + {name: "relative ref from local spec without scheme", ref: "sub/p.rego", basePath: "testdata/policy.yaml", want: filepath.Join("testdata", "sub", "p.rego")}, + {name: "rejects empty ref", ref: "", basePath: localBase, wantErr: true}, + {name: "rejects env scheme", ref: "env://SOME_VAR", basePath: remoteBase, wantErr: true}, + {name: "rejects env scheme from local spec", ref: "env://SOME_VAR", basePath: localBase, wantErr: true}, + {name: "rejects file scheme", ref: "file:///tmp/p.rego", basePath: remoteBase, wantErr: true}, + {name: "rejects file scheme from local spec", ref: "file://p.rego", basePath: localBase, wantErr: true}, + {name: "rejects http scheme", ref: "http://example.com/p.rego", basePath: remoteBase, wantErr: true}, + {name: "rejects relative ref from http spec", ref: script, basePath: "http://example.com/policy.yaml", wantErr: true}, + {name: "rejects chainloop scheme", ref: "chainloop://provider/p", basePath: remoteBase, wantErr: true}, + {name: "rejects https without host", ref: "https:///p.rego", basePath: remoteBase, wantErr: true}, + {name: "rejects opaque https from remote spec", ref: "https:p.rego", basePath: remoteBase, wantErr: true}, + {name: "rejects relative ref from embedded spec", ref: script, wantErr: true}, + {name: "rejects relative ref from chainloop spec", ref: script, basePath: "chainloop://provider/policy", wantErr: true}, + {name: "rejects parent traversal from local spec", ref: "../p.rego", basePath: localBase, wantErr: true}, + {name: "rejects nested parent traversal from local spec", ref: "sub/../../p.rego", basePath: localBase, wantErr: true}, + {name: "rejects absolute path from local spec", ref: "/tmp/p.rego", basePath: localBase, wantErr: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + got, err := resolveScriptRef(tc.ref, tc.basePath) + if tc.wantErr { + s.ErrorIs(err, errInvalidScriptRef) + return + } + + s.Require().NoError(err) + s.Equal(tc.want, got) + }) + } +} + +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 + basePath 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: "ref to env var from embedded spec", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Ref{Ref: "env://" + envVar}}), }, { - name: "absolute file path", - path: "/absolute/path/policy.rego", - expected: false, + name: "ref to env var from remote spec", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Ref{Ref: "env://" + envVar}}), + basePath: "https://example.com/policy.yaml", }, { - name: "file scheme", - path: "file:///path/to/policy.rego", - expected: false, + name: "relative path from embedded spec", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Path{Path: "testdata/workflow.rego"}}), }, { - name: "chainloop scheme", - path: "chainloop://provider/policy", - expected: false, + name: "path traversal from local spec", + policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Path{Path: "../policies/testdata/workflow.rego"}}), + basePath: "file://testdata/policy.yaml", }, { - name: "empty path", - path: "", - expected: false, + name: "legacy path traversal from local spec", + policy: &v12.Policy{ + Metadata: &v12.Metadata{Name: "test"}, + Spec: &v12.PolicySpec{Source: &v12.PolicySpec_Path{Path: "../policies/testdata/workflow.rego"}}, + }, + basePath: "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.basePath) + s.ErrorIs(err, errInvalidScriptRef) + s.NotContains(err.Error(), marker) }) } } From 4142e867c735f9e5e15bab71e6aad56685dc5ea2 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 9 Oct 2026 13:05:07 +0200 Subject: [PATCH 2/4] fix(policies): omit policy source from rego engine parse errors Apply the same parse error sanitization to the rego engine parse sites and cover how the policy spec location used to resolve relative script references is derived. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 5c767ce4-bc27-47f4-8a11-def62c79205b --- pkg/policies/engine/rego/boilerplate.go | 21 +++++------ pkg/policies/engine/rego/rego.go | 6 ++-- pkg/policies/engine/rego/rego_test.go | 47 +++++++++++++++++++++++++ pkg/policies/policies.go | 22 +++++++----- pkg/policies/policies_test.go | 28 +++++++++++++++ 5 files changed, 99 insertions(+), 25 deletions(-) diff --git a/pkg/policies/engine/rego/boilerplate.go b/pkg/policies/engine/rego/boilerplate.go index 3365aca31..7d31335e7 100644 --- a/pkg/policies/engine/rego/boilerplate.go +++ b/pkg/policies/engine/rego/boilerplate.go @@ -49,25 +49,20 @@ type boilerplateData struct { NeedsDefaultViolations bool } -// parseModule parses a Rego module. On failure, the error holds only the code and -// location of the first problem, since OPA parse errors can quote the policy source. -func parseModule(name, source string) (*ast.Module, error) { - module, err := ast.ParseModule(name, source) - if err == nil { - return module, nil - } - +// 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 nil, errors.New("invalid policy") + return errors.New("invalid policy") } first := astErrs[0] if first.Location == nil { - return nil, errors.New(first.Code) + return errors.New(first.Code) } - return nil, fmt.Errorf("%s at line %d, column %d", first.Code, first.Location.Row, first.Location.Col) + 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. @@ -85,9 +80,9 @@ func InjectBoilerplate(policySource []byte, policyName string) ([]byte, error) { originalPolicy := string(policySource) // Parse the policy - module, err := parseModule(policyName, originalPolicy) + 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/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 0c4826e40..42fb852bc 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -396,16 +396,8 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme return nil, nil } - // Relative script references are resolved against the location of the policy spec, - // which is only known for file:// and https:// specs - var basePath string - switch scheme, _ := RefParts(attachment.GetRef()); scheme { - case fileScheme, httpsScheme: - basePath, _ = ExtractDigest(attachment.GetRef()) - } - // load the policy scripts (rego) - scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, basePath) + scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, specBasePath(attachment.GetRef())) if err != nil { return nil, NewPolicyError(err) } @@ -1114,6 +1106,18 @@ func decodeIfBase64Wasm(content []byte) []byte { var errInvalidScriptRef = errors.New("invalid policy script reference") +// specBasePath returns the location of a policy spec, used to resolve its relative script +// references. It is only known for file:// and https:// specs, and empty otherwise. +func specBasePath(specRef string) string { + switch scheme, _ := RefParts(specRef); scheme { + case fileScheme, httpsScheme: + basePath, _ := ExtractDigest(specRef) + return basePath + default: + return "" + } +} + // resolveScriptRef resolves a policy script reference against basePath, the location // of the policy spec that declares it. // Absolute references must use https://. A relative reference is resolved against the diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index b0b657be4..ad53a068b 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1758,6 +1758,34 @@ func loadStatement(file string, s *suite.Suite) *intoto.Statement { return &statement } +func (s *testSuite) TestSpecBasePath() { + const ( + remoteSpec = "https://example.com/policy.yaml" + localSpec = "file://testdata/policy.yaml" + ) + + cases := []struct { + name string + ref string + want string + }{ + {name: "https spec", ref: remoteSpec, want: remoteSpec}, + {name: "https spec with digest", ref: remoteSpec + "@sha256:1234", want: remoteSpec}, + {name: "file spec", ref: localSpec, want: localSpec}, + {name: "file spec with digest", ref: localSpec + "@sha256:1234", want: localSpec}, + {name: "http spec", ref: "http://example.com/policy.yaml"}, + {name: "chainloop spec", ref: "chainloop://provider/policy"}, + {name: "spec without scheme", ref: "policy"}, + {name: "embedded spec", ref: ""}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + s.Equal(tc.want, specBasePath(tc.ref)) + }) + } +} + func (s *testSuite) TestResolveScriptRef() { const ( remoteBase = "https://example.com/policies/policy.yaml" From 0c45f8a85a76360a7562c9b101ed87d09819b5d6 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 9 Oct 2026 13:21:59 +0200 Subject: [PATCH 3/4] fix(policies): follow symlinks when confining local script paths A relative script path in a local policy spec is now checked after resolving symlinks, so a link inside the policy directory cannot point to a file outside it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 5c767ce4-bc27-47f4-8a11-def62c79205b --- pkg/policies/policies.go | 29 +++++++++++++++++++++++++- pkg/policies/policies_test.go | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 42fb852bc..daad5da2c 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" @@ -1168,12 +1169,38 @@ func resolveScriptLocation(ref, basePath string) (string, error) { return "", errors.New("must be a relative path within the policy directory") } - return filepath.Join(filepath.Dir(baseLoc), ref), nil + return confineToDir(filepath.Dir(baseLoc), ref) default: 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) diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index ad53a068b..af3a7693d 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1839,6 +1839,44 @@ func (s *testSuite) TestResolveScriptRef() { } } +func (s *testSuite) TestResolveScriptRefSymlinks() { + root := s.T().TempDir() + specDir := filepath.Join(root, "specs") + s.Require().NoError(os.MkdirAll(filepath.Join(specDir, "sub"), 0o755)) + s.Require().NoError(os.WriteFile(filepath.Join(root, "outside.rego"), []byte("package main"), 0o600)) + s.Require().NoError(os.WriteFile(filepath.Join(specDir, "sub", "inside.rego"), []byte("package main"), 0o600)) + s.Require().NoError(os.Symlink(filepath.Join(root, "outside.rego"), filepath.Join(specDir, "outside_link.rego"))) + s.Require().NoError(os.Symlink(root, filepath.Join(specDir, "outside_dir"))) + s.Require().NoError(os.Symlink(filepath.Join("sub", "inside.rego"), filepath.Join(specDir, "inside_link.rego"))) + + basePath := "file://" + filepath.Join(specDir, "policy.yaml") + + cases := []struct { + name string + ref string + wantErr bool + }{ + {name: "file inside the policy directory", ref: "sub/inside.rego"}, + {name: "symlink to a file inside the policy directory", ref: "inside_link.rego"}, + {name: "rejects symlink to a file outside the policy directory", ref: "outside_link.rego", wantErr: true}, + {name: "rejects file under a symlinked directory outside the policy directory", ref: "outside_dir/outside.rego", wantErr: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + got, err := resolveScriptRef(tc.ref, basePath) + if tc.wantErr { + s.ErrorIs(err, errInvalidScriptRef) + return + } + + s.Require().NoError(err) + _, err = os.Stat(got) + s.NoError(err) + }) + } +} + func (s *testSuite) TestLoadPolicyScriptsFromSpecRejectsInvalidRefs() { const ( envVar = "CHAINLOOP_TEST_SCRIPT_REF" From eabec482141f5d0d781dd26833057caceec853ef Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 9 Oct 2026 14:27:01 +0200 Subject: [PATCH 4/4] test(policies): clarify which policy loading step the script ref tests cover Name the tests and cases after the two loading steps: an attachment loads a policy spec, and the policy spec references its scripts. Only the second step is restricted. Add a test that runs both steps through the verifier. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 5c767ce4-bc27-47f4-8a11-def62c79205b --- pkg/policies/policies.go | 20 +- pkg/policies/policies_test.go | 204 +++++++++++------- .../testdata/policy_script_outside_dir.yaml | 8 + 3 files changed, 149 insertions(+), 83 deletions(-) create mode 100644 pkg/policies/testdata/policy_script_outside_dir.yaml diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index daad5da2c..eac8839aa 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -398,7 +398,7 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme } // load the policy scripts (rego) - scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, specBasePath(attachment.GetRef())) + scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, policyLocation(attachment.GetRef())) if err != nil { return nil, NewPolicyError(err) } @@ -1107,20 +1107,22 @@ func decodeIfBase64Wasm(content []byte) []byte { var errInvalidScriptRef = errors.New("invalid policy script reference") -// specBasePath returns the location of a policy spec, used to resolve its relative script -// references. It is only known for file:// and https:// specs, and empty otherwise. -func specBasePath(specRef string) string { - switch scheme, _ := RefParts(specRef); scheme { +// 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: - basePath, _ := ExtractDigest(specRef) - return basePath + location, _ := ExtractDigest(attachmentRef) + return location default: return "" } } -// resolveScriptRef resolves a policy script reference against basePath, the location -// of the policy spec that declares it. +// 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. diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index af3a7693d..cc7eecc7d 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1758,76 +1758,98 @@ func loadStatement(file string, s *suite.Suite) *intoto.Statement { return &statement } -func (s *testSuite) TestSpecBasePath() { +// 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 ( - remoteSpec = "https://example.com/policy.yaml" - localSpec = "file://testdata/policy.yaml" + remotePolicy = "https://example.com/policy.yaml" + localPolicy = "file://testdata/policy.yaml" ) cases := []struct { - name string - ref string - want string + name string + attachmentRef string + want string }{ - {name: "https spec", ref: remoteSpec, want: remoteSpec}, - {name: "https spec with digest", ref: remoteSpec + "@sha256:1234", want: remoteSpec}, - {name: "file spec", ref: localSpec, want: localSpec}, - {name: "file spec with digest", ref: localSpec + "@sha256:1234", want: localSpec}, - {name: "http spec", ref: "http://example.com/policy.yaml"}, - {name: "chainloop spec", ref: "chainloop://provider/policy"}, - {name: "spec without scheme", ref: "policy"}, - {name: "embedded spec", ref: ""}, + {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, specBasePath(tc.ref)) + 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 ( - remoteBase = "https://example.com/policies/policy.yaml" - localBase = "file://testdata/policy.yaml" + 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 - ref string - basePath string - want string - wantErr bool + name string + scriptRef string + policyLocation string + want string + wantErr bool }{ - {name: "absolute https from remote spec", ref: "https://other.example.com/p.rego", basePath: remoteBase, want: "https://other.example.com/p.rego"}, - {name: "absolute https from local spec", ref: remoteScript, basePath: localBase, want: remoteScript}, - {name: "absolute https from embedded spec", ref: remoteScript, want: remoteScript}, - {name: "relative ref from remote spec", ref: "rego/p.rego", basePath: remoteBase, want: "https://example.com/policies/rego/p.rego"}, - {name: "parent ref from remote spec stays on the URL", ref: "../p.rego", basePath: remoteBase, want: remoteScript}, - {name: "relative ref from local spec", ref: script, basePath: localBase, want: filepath.Join("testdata", script)}, - {name: "relative ref from local spec without scheme", ref: "sub/p.rego", basePath: "testdata/policy.yaml", want: filepath.Join("testdata", "sub", "p.rego")}, - {name: "rejects empty ref", ref: "", basePath: localBase, wantErr: true}, - {name: "rejects env scheme", ref: "env://SOME_VAR", basePath: remoteBase, wantErr: true}, - {name: "rejects env scheme from local spec", ref: "env://SOME_VAR", basePath: localBase, wantErr: true}, - {name: "rejects file scheme", ref: "file:///tmp/p.rego", basePath: remoteBase, wantErr: true}, - {name: "rejects file scheme from local spec", ref: "file://p.rego", basePath: localBase, wantErr: true}, - {name: "rejects http scheme", ref: "http://example.com/p.rego", basePath: remoteBase, wantErr: true}, - {name: "rejects relative ref from http spec", ref: script, basePath: "http://example.com/policy.yaml", wantErr: true}, - {name: "rejects chainloop scheme", ref: "chainloop://provider/p", basePath: remoteBase, wantErr: true}, - {name: "rejects https without host", ref: "https:///p.rego", basePath: remoteBase, wantErr: true}, - {name: "rejects opaque https from remote spec", ref: "https:p.rego", basePath: remoteBase, wantErr: true}, - {name: "rejects relative ref from embedded spec", ref: script, wantErr: true}, - {name: "rejects relative ref from chainloop spec", ref: script, basePath: "chainloop://provider/policy", wantErr: true}, - {name: "rejects parent traversal from local spec", ref: "../p.rego", basePath: localBase, wantErr: true}, - {name: "rejects nested parent traversal from local spec", ref: "sub/../../p.rego", basePath: localBase, wantErr: true}, - {name: "rejects absolute path from local spec", ref: "/tmp/p.rego", basePath: localBase, wantErr: true}, + // 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.ref, tc.basePath) + got, err := resolveScriptRef(tc.scriptRef, tc.policyLocation) if tc.wantErr { s.ErrorIs(err, errInvalidScriptRef) return @@ -1839,32 +1861,33 @@ func (s *testSuite) TestResolveScriptRef() { } } +// TestResolveScriptRefSymlinks covers symlinks in the directory of a local policy spec (step 2) func (s *testSuite) TestResolveScriptRefSymlinks() { root := s.T().TempDir() - specDir := filepath.Join(root, "specs") - s.Require().NoError(os.MkdirAll(filepath.Join(specDir, "sub"), 0o755)) + 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(specDir, "sub", "inside.rego"), []byte("package main"), 0o600)) - s.Require().NoError(os.Symlink(filepath.Join(root, "outside.rego"), filepath.Join(specDir, "outside_link.rego"))) - s.Require().NoError(os.Symlink(root, filepath.Join(specDir, "outside_dir"))) - s.Require().NoError(os.Symlink(filepath.Join("sub", "inside.rego"), filepath.Join(specDir, "inside_link.rego"))) + 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"))) - basePath := "file://" + filepath.Join(specDir, "policy.yaml") + location := "file://" + filepath.Join(policyDir, "policy.yaml") cases := []struct { - name string - ref string - wantErr bool + name string + scriptRef string + wantErr bool }{ - {name: "file inside the policy directory", ref: "sub/inside.rego"}, - {name: "symlink to a file inside the policy directory", ref: "inside_link.rego"}, - {name: "rejects symlink to a file outside the policy directory", ref: "outside_link.rego", wantErr: true}, - {name: "rejects file under a symlinked directory outside the policy directory", ref: "outside_dir/outside.rego", wantErr: true}, + {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.ref, basePath) + got, err := resolveScriptRef(tc.scriptRef, location) if tc.wantErr { s.ErrorIs(err, errInvalidScriptRef) return @@ -1877,6 +1900,8 @@ func (s *testSuite) TestResolveScriptRefSymlinks() { } } +// 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" @@ -1893,47 +1918,78 @@ func (s *testSuite) TestLoadPolicyScriptsFromSpecRejectsInvalidRefs() { } cases := []struct { - name string - policy *v12.Policy - basePath string + name string + policy *v12.Policy + policyLocation string }{ { - name: "ref to env var from embedded spec", + name: "env script ref in an embedded policy", policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Ref{Ref: "env://" + envVar}}), }, { - name: "ref to env var from remote spec", - policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Ref{Ref: "env://" + envVar}}), - basePath: "https://example.com/policy.yaml", + 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: "relative path from embedded spec", + name: "relative script path in an embedded policy", policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Path{Path: "testdata/workflow.rego"}}), }, { - name: "path traversal from local spec", - policy: v2(&v12.PolicySpecV2{Source: &v12.PolicySpecV2_Path{Path: "../policies/testdata/workflow.rego"}}), - basePath: "file://testdata/policy.yaml", + 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: "legacy path traversal from local spec", + 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"}}, }, - basePath: "file://testdata/policy.yaml", + policyLocation: "file://testdata/policy.yaml", }, } for _, tc := range cases { s.Run(tc.name, func() { - _, err := LoadPolicyScriptsFromSpec(tc.policy, v12.CraftingSchema_Material_MATERIAL_TYPE_UNSPECIFIED, tc.basePath) + _, 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) + }) + } +} + func (s *testSuite) TestPolicyAttachmentGate() { trueGate := true falseGate := false 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