From 971f452d573bcbe0eec5ae5d72f3a4b2ac3ae0d7 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 1 Jul 2026 13:30:39 +0200 Subject: [PATCH] feat(cli): per-policy scoping for --policy-input-from-file Runtime policy inputs supplied via --policy-input-from-file previously lived in a single global namespace keyed only by input name, so an input was applied to every policy attachment that declared it and could not be targeted at a specific policy. This prevented feeding one curated list into different inputs on different policies (e.g. ignored_paths on a customer-signed gate versus third_party_paths on a vendor-keys gate). Add an optional policy-scope prefix to the flag value: [:]=[:] The unscoped form keeps the previous global behavior. The scoped form applies the input only to the attachment whose policy name or ref matches the scope, normalizing scheme, org and @sha256: digest and honoring a pinned version. Global and scoped inputs for the same policy merge additively. A scope that matches no policy on the material is logged as a warning. runtime_input_overrides continues to record, per policy, which inputs applied. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 21d09b3d-bdcb-4e52-9aca-56aa3c1b5139, 92f34c12-d29d-4d4a-897a-4afea9b1ee86 --- app/cli/cmd/attestation_add.go | 9 +- app/cli/documentation/cli-reference.mdx | 7 +- app/cli/pkg/action/attestation_add.go | 40 +++-- app/cli/pkg/action/attestation_add_test.go | 56 ++++++ app/cli/pkg/action/policy_input_file.go | 56 ++++-- app/cli/pkg/action/policy_input_file_test.go | 40 +++++ pkg/attestation/crafter/crafter.go | 7 +- pkg/policies/policies.go | 58 +++--- pkg/policies/policies_test.go | 49 +++++ pkg/policies/runtime_inputs.go | 155 ++++++++++++++++ pkg/policies/runtime_inputs_test.go | 170 +++++++++++++++++- pkg/policies/testdata/scoped_signed.yaml | 19 ++ pkg/policies/testdata/scoped_vendor_keys.yaml | 19 ++ 13 files changed, 619 insertions(+), 66 deletions(-) create mode 100644 pkg/policies/runtime_inputs.go create mode 100644 pkg/policies/testdata/scoped_signed.yaml create mode 100644 pkg/policies/testdata/scoped_vendor_keys.yaml diff --git a/app/cli/cmd/attestation_add.go b/app/cli/cmd/attestation_add.go index 37dd62353..f99ff83b1 100644 --- a/app/cli/cmd/attestation_add.go +++ b/app/cli/cmd/attestation_add.go @@ -72,7 +72,12 @@ func newAttestationAddCmd() *cobra.Command { # Feed a policy input from a column of a CSV/JSON file (e.g. the ignored_paths exclusion list for the sigcheck binary-signing policies). # The :column suffix selects the column; it defaults to the input name when omitted. The file is also recorded as EVIDENCE. chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \ - --policy-input-from-file ignored_paths=exception.csv:Path`, + --policy-input-from-file ignored_paths=exception.csv:Path + + # Scope an input to a specific policy with a : prefix so it only applies to that policy attachment. + chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \ + --policy-input-from-file trusted-binaries-signed:ignored_paths=exception.csv:Path \ + --policy-input-from-file trusted-binaries-vendor-keys:third_party_paths=exception.csv:Path`, RunE: func(cmd *cobra.Command, _ []string) error { a, err := action.NewAttestationAdd( &action.AttestationAddOpts{ @@ -159,7 +164,7 @@ func newAttestationAddCmd() *cobra.Command { flagAttestationID(cmd) cmd.Flags().StringVar(&kind, "kind", "", fmt.Sprintf("kind of the material to be recorded: %q", schemaapi.ListAvailableMaterialKind())) cmd.Flags().BoolVar(&noStrictValidation, "no-strict-validation", false, "skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)") - cmd.Flags().StringArrayVar(&policyInputFromFileFlag, "policy-input-from-file", nil, "feed a policy input from a column of a CSV or JSON file, in the format =[:] (e.g. ignored_paths=exception.csv:Path); is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.") + cmd.Flags().StringArrayVar(&policyInputFromFileFlag, "policy-input-from-file", nil, "feed a policy input from a column of a CSV or JSON file, in the format [:]=[:] (e.g. ignored_paths=exception.csv:Path); an optional : prefix scopes the input to a single policy (matched by name or ref), otherwise it applies to every declaring policy; is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.") // Optional OCI registry credentials cmd.Flags().StringVar(®istryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName)) diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index b4bf901dd..54db810e4 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -249,6 +249,11 @@ Feed a policy input from a column of a CSV/JSON file (e.g. the ignored_paths exc The :column suffix selects the column; it defaults to the input name when omitted. The file is also recorded as EVIDENCE. chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \ --policy-input-from-file ignored_paths=exception.csv:Path + +Scope an input to a specific policy with a : prefix so it only applies to that policy attachment. +chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \ +--policy-input-from-file trusted-binaries-signed:ignored_paths=exception.csv:Path \ +--policy-input-from-file trusted-binaries-vendor-keys:third_party_paths=exception.csv:Path ``` Options @@ -260,7 +265,7 @@ Options --kind string kind of the material to be recorded: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "OSSF_SCORECARD_JSON" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"] --name string name of the material as shown in the contract --no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON) ---policy-input-from-file stringArray feed a policy input from a column of a CSV or JSON file, in the format =[:] (e.g. ignored_paths=exception.csv:Path); is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE. +--policy-input-from-file stringArray feed a policy input from a column of a CSV or JSON file, in the format [:]=[:] (e.g. ignored_paths=exception.csv:Path); an optional : prefix scopes the input to a single policy (matched by name or ref), otherwise it applies to every declaring policy; is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE. --registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD) --registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER) --registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME) diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index 3778a9a94..b3f24f471 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -27,6 +27,7 @@ import ( api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/chainloop-dev/chainloop/pkg/policies" "google.golang.org/grpc" ) @@ -181,39 +182,48 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa // runtimeInputAddOpts wraps the runtime inputs as crafter add options, or // returns nil when there are none. Defined at package scope so it can name the // crafter package type (the Run method shadows it with a local variable). -func runtimeInputAddOpts(runtimeInputs map[string]string) []crafter.AddOpt { - if len(runtimeInputs) == 0 { +func runtimeInputAddOpts(runtimeInputs *policies.RuntimeInputs) []crafter.AddOpt { + if runtimeInputs == nil || (len(runtimeInputs.Global) == 0 && len(runtimeInputs.Scoped) == 0) { return nil } return []crafter.AddOpt{crafter.WithRuntimeInputs(runtimeInputs)} } -// buildRuntimeInputs reads each policy input file and returns a map of policy -// input name to its extracted values, ready to be merged onto contract -// arguments. Values are newline-joined, matching the engine's existing -// multi-value encoding (it splits inputs back on newlines and commas). As with -// contract-declared arguments, individual values must not embed those -// delimiters; path globs, the intended use, never do. -func buildRuntimeInputs(policyInputFiles []*PolicyInputFromFile) (map[string]string, error) { +// buildRuntimeInputs reads each policy input file and returns the extracted +// values grouped for the policy engine: unscoped entries under Global and +// policy-scoped entries under Scoped[policy]. Values are newline-joined and +// accumulated via policies.MergeRuntimeInputs so repeated inputs merge using the +// same multi-value encoding the engine expects (it splits inputs back on +// newlines and commas). As with contract-declared arguments, individual values +// must not embed those delimiters; path globs, the intended use, never do. +func buildRuntimeInputs(policyInputFiles []*PolicyInputFromFile) (*policies.RuntimeInputs, error) { if len(policyInputFiles) == 0 { return nil, nil } - runtimeInputs := make(map[string]string, len(policyInputFiles)) + ri := &policies.RuntimeInputs{ + Global: map[string]string{}, + Scoped: map[string]map[string]string{}, + } for _, pif := range policyInputFiles { values, err := ExtractColumnValues(pif.File, pif.Column) if err != nil { return nil, fmt.Errorf("extracting %q from %q: %w", pif.Column, pif.File, err) } - joined := strings.Join(values, "\n") - if existing := runtimeInputs[pif.Input]; existing != "" { - runtimeInputs[pif.Input] = existing + "\n" + joined + + // Unscoped entries go to Global; policy-scoped entries to their own + // Scoped[policy] map. Because global and scoped values live in separate + // maps, they never collide here even when they share an input name; + // forPolicy is what later merges a policy's scoped values over Global. + add := map[string]string{pif.Input: strings.Join(values, "\n")} + if pif.Policy == "" { + ri.Global = policies.MergeRuntimeInputs(ri.Global, add) } else { - runtimeInputs[pif.Input] = joined + ri.Scoped[pif.Policy] = policies.MergeRuntimeInputs(ri.Scoped[pif.Policy], add) } } - return runtimeInputs, nil + return ri, nil } // addPolicyInputEvidence adds each policy input file as an EVIDENCE material, diff --git a/app/cli/pkg/action/attestation_add_test.go b/app/cli/pkg/action/attestation_add_test.go index 740b41089..0b35052e4 100644 --- a/app/cli/pkg/action/attestation_add_test.go +++ b/app/cli/pkg/action/attestation_add_test.go @@ -16,12 +16,16 @@ package action import ( + "os" + "path/filepath" "regexp" "testing" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" + "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // materialNameRe mirrors the DNS-1123-style constraint enforced on material @@ -112,3 +116,55 @@ func TestBuildRuntimeInputsNil(t *testing.T) { assert.NoError(t, err) assert.Nil(t, got) } + +func TestBuildRuntimeInputs(t *testing.T) { + dir := t.TempDir() + // A single file with two columns reused across inputs. + path := filepath.Join(dir, "exception.csv") + require.NoError(t, os.WriteFile(path, []byte("Path,Extra\na.dll,x\nb.dll,y\n"), 0600)) + + t.Run("unscoped inputs land in Global", func(t *testing.T) { + got, err := buildRuntimeInputs([]*PolicyInputFromFile{ + {Input: "ignored_paths", Column: "Path", File: path}, + }) + require.NoError(t, err) + assert.Equal(t, &policies.RuntimeInputs{ + Global: map[string]string{"ignored_paths": "a.dll\nb.dll"}, + Scoped: map[string]map[string]string{}, + }, got) + }) + + t.Run("scoped inputs land under their policy", func(t *testing.T) { + got, err := buildRuntimeInputs([]*PolicyInputFromFile{ + {Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: path}, + {Policy: "trusted-binaries-vendor-keys", Input: "third_party_paths", Column: "Path", File: path}, + }) + require.NoError(t, err) + assert.Equal(t, &policies.RuntimeInputs{ + Global: map[string]string{}, + Scoped: map[string]map[string]string{ + "trusted-binaries-signed": {"ignored_paths": "a.dll\nb.dll"}, + "trusted-binaries-vendor-keys": {"third_party_paths": "a.dll\nb.dll"}, + }, + }, got) + }) + + t.Run("repeated scope+input merges additively", func(t *testing.T) { + got, err := buildRuntimeInputs([]*PolicyInputFromFile{ + {Policy: "p", Input: "ignored_paths", Column: "Path", File: path}, + {Policy: "p", Input: "ignored_paths", Column: "Extra", File: path}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{"ignored_paths": "a.dll\nb.dll\nx\ny"}, got.Scoped["p"]) + }) + + t.Run("global and scoped coexist", func(t *testing.T) { + got, err := buildRuntimeInputs([]*PolicyInputFromFile{ + {Input: "ignored_paths", Column: "Path", File: path}, + {Policy: "p", Input: "ignored_paths", Column: "Extra", File: path}, + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{"ignored_paths": "a.dll\nb.dll"}, got.Global) + assert.Equal(t, map[string]string{"ignored_paths": "x\ny"}, got.Scoped["p"]) + }) +} diff --git a/app/cli/pkg/action/policy_input_file.go b/app/cli/pkg/action/policy_input_file.go index a3347b239..9f7424aca 100644 --- a/app/cli/pkg/action/policy_input_file.go +++ b/app/cli/pkg/action/policy_input_file.go @@ -30,6 +30,9 @@ import ( // PolicyInputFromFile describes a single --policy-input-from-file flag value: a // policy input name fed from a named column of a CSV or JSON file. type PolicyInputFromFile struct { + // Policy optionally scopes the input to a specific policy (its name or ref). + // Empty means the input is global and applies to every declaring policy. + Policy string // Input is the destination policy input name (e.g. "ignored_paths"). Input string // Column is the file column/field to extract. Defaults to Input. @@ -38,20 +41,53 @@ type PolicyInputFromFile struct { File string } +// scopeDelimiter separates an optional policy scope from the input name on the +// left-hand side of a --policy-input-from-file value. It is ":" (shell-inert in +// bash, sh and zsh). A policy ref can itself contain ":" (in a "://" scheme or +// an "@sha256:" digest) while an input name never can, so the scope is split off +// at the *last* ":" of the left-hand side. +const scopeDelimiter = ":" + +// digestScheme is the digest prefix used in versioned policy refs +// (@sha256:). Used to detect a versioned scope whose input name +// was omitted, which would otherwise be mistaken for the digest. +const digestScheme = "@sha256" + // ParsePolicyInputFromFile parses a single flag value of the form -// "=[:]". The column is optional and defaults to the input -// name. A column is always a single, top-level field/header name — never a path -// or a nested key. The column is the segment after the last ":"; since a column -// name never contains a path separator, a trailing ":<...>" whose ":" belongs to -// the file (a Windows drive letter like C:\data\... or a URL scheme like -// https://) is not mistaken for a column. +// "[:]=[:]". The optional ":" prefix scopes +// the input to a single policy (matched against its name or ref); without it the +// input is global. Because a policy ref may itself contain ":" but an input name +// never does, the scope is taken as everything before the *last* ":" on the +// left of "=". The column is optional and defaults to the input name. A column +// is always a single, top-level field/header name — never a path or a nested +// key. The column is the segment after the last ":"; since a column name never +// contains a path separator, a trailing ":<...>" whose ":" belongs to the file +// (a Windows drive letter like C:\data\... or a URL scheme like https://) is not +// mistaken for a column. func ParsePolicyInputFromFile(raw string) (*PolicyInputFromFile, error) { - input, rhs, found := strings.Cut(raw, "=") + lhs, rhs, found := strings.Cut(raw, "=") if !found { - return nil, fmt.Errorf("invalid --policy-input-from-file %q: expected =[:]", raw) + return nil, fmt.Errorf("invalid --policy-input-from-file %q: expected [:]=[:]", raw) + } + + // Split off the optional ":" scope prefix at the last ":": a policy + // ref may contain colons (scheme, digest) but the input name never does. + var policy, input string + if i := strings.LastIndex(lhs, scopeDelimiter); i >= 0 { + policy = strings.TrimSpace(lhs[:i]) + input = strings.TrimSpace(lhs[i+1:]) + if policy == "" { + return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing policy scope before %q", raw, scopeDelimiter) + } + // A bare "@sha256:" (no input) would be mis-split into + // policy "@sha256" and input ""; reject it with guidance. + if strings.HasSuffix(policy, digestScheme) { + return nil, fmt.Errorf("invalid --policy-input-from-file %q: versioned policy scope is missing an input name; expected @sha256::=", raw) + } + } else { + input = strings.TrimSpace(lhs) } - input = strings.TrimSpace(input) rhs = strings.TrimSpace(rhs) if input == "" { return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing input name", raw) @@ -75,7 +111,7 @@ func ParsePolicyInputFromFile(raw string) (*PolicyInputFromFile, error) { return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing file path", raw) } - return &PolicyInputFromFile{Input: input, Column: column, File: file}, nil + return &PolicyInputFromFile{Policy: policy, Input: input, Column: column, File: file}, nil } // ExtractColumnValues reads the given CSV or JSON file and returns the values of diff --git a/app/cli/pkg/action/policy_input_file_test.go b/app/cli/pkg/action/policy_input_file_test.go index 1b12105da..2ba267a1e 100644 --- a/app/cli/pkg/action/policy_input_file_test.go +++ b/app/cli/pkg/action/policy_input_file_test.go @@ -61,6 +61,46 @@ func TestParsePolicyInputFromFile(t *testing.T) { raw: "versions=exception.csv:Product Version", want: &PolicyInputFromFile{Input: "versions", Column: "Product Version", File: "exception.csv"}, }, + { + name: "policy-scoped input", + raw: "trusted-binaries-signed:ignored_paths=exception.csv:Path", + want: &PolicyInputFromFile{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: "exception.csv"}, + }, + { + name: "policy-scoped input defaults the column to the input", + raw: "trusted-binaries-signed:ignored_paths=exception.csv", + want: &PolicyInputFromFile{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "ignored_paths", File: "exception.csv"}, + }, + { + name: "policy-scoped input pinned to a version", + raw: "trusted-binaries-signed@sha256:deadbeef:ignored_paths=exception.csv:Path", + want: &PolicyInputFromFile{Policy: "trusted-binaries-signed@sha256:deadbeef", Input: "ignored_paths", Column: "Path", File: "exception.csv"}, + }, + { + name: "provider-style scope keeps its colon", + raw: "builtin:trusted-binaries-signed:ignored_paths=exception.csv:Path", + want: &PolicyInputFromFile{Policy: "builtin:trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: "exception.csv"}, + }, + { + name: "policy scope surrounding whitespace trimmed", + raw: " trusted-binaries-signed : ignored_paths = exception.csv : Path ", + want: &PolicyInputFromFile{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: "exception.csv"}, + }, + { + name: "empty policy scope", + raw: ":ignored_paths=exception.csv", + wantErr: true, + }, + { + name: "policy scope with empty input", + raw: "trusted-binaries-signed:=exception.csv", + wantErr: true, + }, + { + name: "versioned scope missing an input name", + raw: "trusted-binaries-signed@sha256:deadbeef=exception.csv", + wantErr: true, + }, { name: "surrounding whitespace trimmed", raw: " ignored_paths = exception.csv : Path ", diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 5a2db615b..4d97f0df9 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -567,14 +567,15 @@ type AddOpt func(*addOpts) type addOpts struct { // runtimeInputs holds policy input values supplied at runtime (e.g. sourced // from a file via --policy-input-from-file). They are merged additively onto - // the contract arguments when evaluating the standalone material policies. - runtimeInputs map[string]string + // the contract arguments when evaluating the standalone material policies, + // either globally or scoped to a specific policy. + runtimeInputs *policies.RuntimeInputs } // WithRuntimeInputs supplies policy input values that are merged additively onto // the contract arguments when evaluating the standalone material policies for // this add. Used by --policy-input-from-file. -func WithRuntimeInputs(inputs map[string]string) AddOpt { +func WithRuntimeInputs(inputs *policies.RuntimeInputs) AddOpt { return func(o *addOpts) { o.runtimeInputs = inputs } diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index bb58ac983..2e417cbf4 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -20,7 +20,6 @@ import ( "encoding/base64" "errors" "fmt" - "maps" "net/url" "path/filepath" "regexp" @@ -100,7 +99,7 @@ type PolicyVerifier struct { groupCache cache.Cache[*groupWithReference] projectName string projectVersionName string - runtimeInputs map[string]string + runtimeInputs *RuntimeInputs } var _ Verifier = (*PolicyVerifier)(nil) @@ -117,7 +116,7 @@ type PolicyVerifierOptions struct { GroupCache cache.Cache[*groupWithReference] ProjectName string ProjectVersionName string - RuntimeInputs map[string]string + RuntimeInputs *RuntimeInputs } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -191,7 +190,8 @@ func WithProjectContext(name, version string) PolicyVerifierOption { // WithRuntimeInputs sets policy input values supplied at runtime (e.g. sourced // from a file via --policy-input-from-file). They are merged additively onto // the contract-declared arguments during standalone material policy evaluation. -func WithRuntimeInputs(inputs map[string]string) PolicyVerifierOption { +// Inputs can be global or scoped to a specific policy; see RuntimeInputs. +func WithRuntimeInputs(inputs *RuntimeInputs) PolicyVerifierOption { return func(o *PolicyVerifierOptions) { o.RuntimeInputs = inputs } @@ -256,6 +256,10 @@ func (pv *PolicyVerifier) VerifyMaterial(ctx context.Context, material *v12.Atte return nil, NewPolicyError(err) } + // Track which scoped runtime inputs matched a policy so we can warn about + // scopes that matched nothing (e.g. a typo in the policy name). + tracker := newScopeTracker() + results := make([]*v12.PolicyEvaluation, len(attachments)) g, gCtx := errgroup.WithContext(ctx) g.SetLimit(pv.maxConcurrency) @@ -263,7 +267,7 @@ func (pv *PolicyVerifier) VerifyMaterial(ctx context.Context, material *v12.Atte for i, attachment := range attachments { g.Go(func() error { ev, err := pv.evaluatePolicyAttachment(gCtx, attachment, subject, - &evalOpts{kind: material.MaterialType, name: material.GetId(), runtimeInputs: pv.runtimeInputs}, + &evalOpts{kind: material.MaterialType, name: material.GetId(), runtimeInputs: pv.runtimeInputs, scopeTracker: tracker}, ) if err != nil { return NewPolicyError(err) @@ -277,6 +281,11 @@ func (pv *PolicyVerifier) VerifyMaterial(ctx context.Context, material *v12.Atte return nil, err } + for _, scope := range tracker.unmatched(pv.runtimeInputs) { + pv.logger.Warn().Str("scope", scope).Str("material", material.GetId()). + Msg("policy input scoped to a policy that matched no attachment for this material — check the policy name") + } + // Filter nil entries (skipped policies) for _, ev := range results { if ev != nil { @@ -296,30 +305,10 @@ type evalOpts struct { // file via --policy-input-from-file). They are merged additively onto the // contract-declared arguments, kept separate from bindings so policy-group // argument resolution is never mis-flagged as a runtime override. - runtimeInputs map[string]string -} - -// mergeRuntimeInputs returns the contract arguments with the runtime inputs -// merged in additively: when both define the same key, the runtime value is -// appended after the contract value (newline-separated) so file-sourced -// exemptions add to, rather than replace, contract-declared ones. The input -// maps are not mutated. -func mergeRuntimeInputs(with, runtimeInputs map[string]string) map[string]string { - if len(runtimeInputs) == 0 { - return with - } - - merged := make(map[string]string, len(with)+len(runtimeInputs)) - maps.Copy(merged, with) - for k, v := range runtimeInputs { - if existing := merged[k]; existing != "" { - merged[k] = existing + "\n" + v - } else { - merged[k] = v - } - } - - return merged + runtimeInputs *RuntimeInputs + // scopeTracker records which scoped runtime inputs matched a policy so the + // caller can warn about scopes that matched nothing. May be nil. + scopeTracker *scopeTracker } // shouldEvaluateAtPhase checks if a policy should be evaluated at the given phase. @@ -396,9 +385,12 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme pv.logger.Debug().Msgf("evaluating policy %s against attestation", policy.Metadata.Name) } - // Merge runtime-supplied inputs additively onto the contract arguments - // before computing the effective values. - with := mergeRuntimeInputs(attachment.GetWith(), opts.runtimeInputs) + // Resolve the runtime-supplied inputs that apply to this policy (global plus + // any scoped to its name/ref) and merge them additively onto the contract + // arguments before computing the effective values. + effectiveRuntime, matchedScopes := opts.runtimeInputs.forPolicy(policy.GetMetadata().GetName(), attachment.GetRef()) + opts.scopeTracker.mark(matchedScopes...) + with := MergeRuntimeInputs(attachment.GetWith(), effectiveRuntime) args, err := ComputeArguments(policy.GetMetadata().GetName(), policy.GetSpec().GetInputs(), with, opts.bindings, pv.logger) if err != nil { @@ -409,7 +401,7 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme // into the computed args because the policy declares them). The values // themselves live in `with`; this only flags the overridden input names. var runtimeInputOverrides []string - for k := range opts.runtimeInputs { + for k := range effectiveRuntime { if _, ok := args[k]; ok { runtimeInputOverrides = append(runtimeInputOverrides, k) } diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index 723993c2d..091b22ba4 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -593,6 +593,55 @@ func (s *testSuite) TestInvalidInlineMaterial() { s.Equal("Not made with syft", res[0].Violations[0].Message) } +// TestVerifyMaterialScopedRuntimeInputs reproduces the trusted-binaries scenario +// from PFM-6530: two policies both declare `ignored_paths`, and a runtime input +// scoped to one of them must land only on that policy, not the other. +func (s *testSuite) TestVerifyMaterialScopedRuntimeInputs() { + schema := &v12.CraftingSchema{ + Policies: &v12.Policies{ + Materials: []*v12.PolicyAttachment{ + {Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/scoped_signed.yaml"}}, + {Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/scoped_vendor_keys.yaml"}}, + }, + }, + } + material := &v1.Attestation_Material{ + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{ + Content: []byte(`{}`), + }}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + InlineCas: true, + } + + runtimeInputs := &RuntimeInputs{ + Scoped: map[string]map[string]string{ + "trusted-binaries-signed": {"ignored_paths": "a.dll\nb.dll"}, + }, + } + + verifier := NewPolicyVerifier(schema.Policies, nil, &s.logger, WithRuntimeInputs(runtimeInputs)) + + res, err := verifier.VerifyMaterial(context.TODO(), material, "") + s.Require().NoError(err) + s.Require().Len(res, 2) + + byName := make(map[string]*v1.PolicyEvaluation, len(res)) + for _, r := range res { + byName[r.GetName()] = r + } + + signed := byName["trusted-binaries-signed"] + s.Require().NotNil(signed) + s.Equal("a.dll\nb.dll", signed.GetWith()["ignored_paths"]) + s.Equal([]string{"ignored_paths"}, signed.GetRuntimeInputOverrides()) + + // The scoped input must NOT leak onto the other policy. + vendor := byName["trusted-binaries-vendor-keys"] + s.Require().NotNil(vendor) + s.Empty(vendor.GetWith()["ignored_paths"]) + s.Empty(vendor.GetRuntimeInputOverrides()) +} + func (s *testSuite) TestLoadPolicySpec() { var cases = []struct { name string diff --git a/pkg/policies/runtime_inputs.go b/pkg/policies/runtime_inputs.go new file mode 100644 index 000000000..67b478373 --- /dev/null +++ b/pkg/policies/runtime_inputs.go @@ -0,0 +1,155 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policies + +import ( + "maps" + "slices" + "sync" +) + +// RuntimeInputs holds policy input values supplied at runtime (e.g. via +// --policy-input-from-file). Inputs are either global (applied to every policy +// attachment that declares them) or scoped to a specific policy (applied only +// to the attachment whose metadata name or ref matches the scope key). +type RuntimeInputs struct { + // Global inputs, keyed by input name. + Global map[string]string + // Scoped inputs, keyed by policy scope (a policy name or ref) then input name. + Scoped map[string]map[string]string +} + +// forPolicy returns the runtime inputs that apply to a policy attachment +// identified by its metadata name and raw ref, together with the scope keys +// that matched. The returned map merges the global inputs with any scoped +// entries whose key matches the attachment (additively when they share an +// input name). Returns (nil, nil) when nothing applies. Nil-safe. +func (ri *RuntimeInputs) forPolicy(name, ref string) (map[string]string, []string) { + if ri == nil || (len(ri.Global) == 0 && len(ri.Scoped) == 0) { + return nil, nil + } + + effective := ri.Global + var matched []string + for scope, inputs := range ri.Scoped { + if policyScopeMatches(scope, name, ref) { + matched = append(matched, scope) + effective = MergeRuntimeInputs(effective, inputs) + } + } + + return effective, matched +} + +// policyScopeMatches reports whether a runtime-input scope key targets the +// policy attachment identified by its metadata name and raw ref. A scope +// matches when it equals the name or the ref exactly, or when its bare name +// (scheme, org and @sha256: digest stripped) matches; if the scope pins a +// digest, the ref must carry the same digest, otherwise any version matches. +func policyScopeMatches(scope, name, ref string) bool { + if scope == "" { + return false + } + if scope == name || scope == ref { + return true + } + + scopeName, scopeDigest := splitPolicyRef(scope) + if scopeName == "" { + return false + } + refName, refDigest := splitPolicyRef(ref) + if scopeName != refName && scopeName != name { + return false + } + if scopeDigest != "" { + return scopeDigest == refDigest + } + return true +} + +// splitPolicyRef normalizes a policy reference to its bare name and digest, +// stripping any scheme, org scope and @sha256: version using the same parsers +// the loaders use. +func splitPolicyRef(ref string) (name, digest string) { + return ExtractDigest(ProviderParts(ref).Name) +} + +// MergeRuntimeInputs returns the contract arguments with the runtime inputs +// merged in additively: when both define the same key, the runtime value is +// appended after the contract value (newline-separated) so file-sourced +// exemptions add to, rather than replace, contract-declared ones. The input +// maps are not mutated. Exported so callers assembling runtime inputs (e.g. the +// CLI's --policy-input-from-file handling) reuse the same multi-value encoding. +func MergeRuntimeInputs(with, runtimeInputs map[string]string) map[string]string { + if len(runtimeInputs) == 0 { + return with + } + + merged := make(map[string]string, len(with)+len(runtimeInputs)) + maps.Copy(merged, with) + for k, v := range runtimeInputs { + if existing := merged[k]; existing != "" { + merged[k] = existing + "\n" + v + } else { + merged[k] = v + } + } + + return merged +} + +// scopeTracker records, concurrency-safely, which runtime-input scope keys were +// matched by at least one policy attachment during a material evaluation. +type scopeTracker struct { + mu sync.Mutex + seen map[string]struct{} +} + +func newScopeTracker() *scopeTracker { + return &scopeTracker{seen: make(map[string]struct{})} +} + +func (t *scopeTracker) mark(keys ...string) { + if t == nil || len(keys) == 0 { + return + } + t.mu.Lock() + defer t.mu.Unlock() + for _, k := range keys { + t.seen[k] = struct{}{} + } +} + +// unmatched returns the sorted scope keys declared in ri that were never marked +// (i.e. matched no policy attachment), so the caller can warn about likely +// typos. Nil-safe. +func (t *scopeTracker) unmatched(ri *RuntimeInputs) []string { + if t == nil || ri == nil { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + + var out []string + for scope := range ri.Scoped { + if _, ok := t.seen[scope]; !ok { + out = append(out, scope) + } + } + slices.Sort(out) + return out +} diff --git a/pkg/policies/runtime_inputs_test.go b/pkg/policies/runtime_inputs_test.go index fb39193a8..619c4a1fa 100644 --- a/pkg/policies/runtime_inputs_test.go +++ b/pkg/policies/runtime_inputs_test.go @@ -62,7 +62,7 @@ func TestMergeRuntimeInputs(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - got := mergeRuntimeInputs(tc.with, tc.runtimeInputs) + got := MergeRuntimeInputs(tc.with, tc.runtimeInputs) assert.Equal(t, tc.want, got) }) } @@ -73,8 +73,174 @@ func TestMergeRuntimeInputsDoesNotMutate(t *testing.T) { with := map[string]string{"ignored_paths": "a"} runtimeInputs := map[string]string{"ignored_paths": "b"} - _ = mergeRuntimeInputs(with, runtimeInputs) + _ = MergeRuntimeInputs(with, runtimeInputs) assert.Equal(t, map[string]string{"ignored_paths": "a"}, with) assert.Equal(t, map[string]string{"ignored_paths": "b"}, runtimeInputs) } + +func TestPolicyScopeMatches(t *testing.T) { + testCases := []struct { + name string + scope string + pname string // policy metadata name + ref string // attachment raw ref + want bool + }{ + { + name: "exact metadata name", + scope: "trusted-binaries-signed", + pname: "trusted-binaries-signed", + ref: "chainloop://trusted-binaries-signed@sha256:abc", + want: true, + }, + { + name: "exact raw ref", + scope: "chainloop://trusted-binaries-signed@sha256:abc", + pname: "", + ref: "chainloop://trusted-binaries-signed@sha256:abc", + want: true, + }, + { + name: "bare name matches versioned ref when unversioned", + scope: "trusted-binaries-signed", + pname: "", + ref: "chainloop://trusted-binaries-signed@sha256:abc", + want: true, + }, + { + name: "bare name matches plain ref", + scope: "trusted-binaries-signed", + pname: "", + ref: "trusted-binaries-signed", + want: true, + }, + { + name: "org-scoped scope matches org-scoped ref", + scope: "myorg/trusted-binaries-signed", + pname: "trusted-binaries-signed", + ref: "chainloop://myorg/trusted-binaries-signed", + want: true, + }, + { + name: "pinned digest matches same digest", + scope: "trusted-binaries-signed@sha256:abc", + pname: "trusted-binaries-signed", + ref: "chainloop://trusted-binaries-signed@sha256:abc", + want: true, + }, + { + name: "pinned digest does not match different digest", + scope: "trusted-binaries-signed@sha256:abc", + pname: "trusted-binaries-signed", + ref: "chainloop://trusted-binaries-signed@sha256:xyz", + want: false, + }, + { + name: "different policy name does not match", + scope: "other-policy", + pname: "trusted-binaries-signed", + ref: "chainloop://trusted-binaries-signed@sha256:abc", + want: false, + }, + { + name: "empty scope never matches", + scope: "", + pname: "trusted-binaries-signed", + ref: "trusted-binaries-signed", + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, policyScopeMatches(tc.scope, tc.pname, tc.ref)) + }) + } +} + +func TestRuntimeInputsForPolicy(t *testing.T) { + t.Run("nil receiver returns nothing", func(t *testing.T) { + var ri *RuntimeInputs + got, matched := ri.forPolicy("p", "p") + assert.Nil(t, got) + assert.Nil(t, matched) + }) + + t.Run("global inputs apply to every policy", func(t *testing.T) { + ri := &RuntimeInputs{Global: map[string]string{"ignored_paths": "a"}} + got, matched := ri.forPolicy("some-policy", "some-policy") + assert.Equal(t, map[string]string{"ignored_paths": "a"}, got) + assert.Empty(t, matched) + }) + + t.Run("scoped input applies only to the matching policy", func(t *testing.T) { + ri := &RuntimeInputs{Scoped: map[string]map[string]string{ + "trusted-binaries-signed": {"ignored_paths": "a"}, + }} + + got, matched := ri.forPolicy("trusted-binaries-signed", "chainloop://trusted-binaries-signed@sha256:abc") + assert.Equal(t, map[string]string{"ignored_paths": "a"}, got) + assert.ElementsMatch(t, []string{"trusted-binaries-signed"}, matched) + + got, matched = ri.forPolicy("trusted-binaries-vendor-keys", "chainloop://trusted-binaries-vendor-keys") + assert.Empty(t, got) + assert.Empty(t, matched) + }) + + t.Run("global and scoped merge additively for the same input", func(t *testing.T) { + ri := &RuntimeInputs{ + Global: map[string]string{"ignored_paths": "g"}, + Scoped: map[string]map[string]string{ + "trusted-binaries-signed": {"ignored_paths": "s"}, + }, + } + got, matched := ri.forPolicy("trusted-binaries-signed", "trusted-binaries-signed") + assert.Equal(t, map[string]string{"ignored_paths": "g\ns"}, got) + assert.ElementsMatch(t, []string{"trusted-binaries-signed"}, matched) + }) + + t.Run("does not mutate the global map", func(t *testing.T) { + ri := &RuntimeInputs{ + Global: map[string]string{"ignored_paths": "g"}, + Scoped: map[string]map[string]string{"p": {"ignored_paths": "s"}}, + } + _, _ = ri.forPolicy("p", "p") + assert.Equal(t, map[string]string{"ignored_paths": "g"}, ri.Global) + }) +} + +func TestScopeTrackerUnmatched(t *testing.T) { + testCases := []struct { + name string + ri *RuntimeInputs + matched []string + want []string + }{ + { + name: "nil runtime inputs", + ri: nil, + want: nil, + }, + { + name: "all scopes matched", + ri: &RuntimeInputs{Scoped: map[string]map[string]string{"a": {}, "b": {}}}, + matched: []string{"a", "b"}, + want: nil, + }, + { + name: "unmatched scopes returned sorted", + ri: &RuntimeInputs{Scoped: map[string]map[string]string{"zebra": {}, "alpha": {}, "beta": {}}}, + matched: []string{"beta"}, + want: []string{"alpha", "zebra"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tracker := newScopeTracker() + tracker.mark(tc.matched...) + assert.Equal(t, tc.want, tracker.unmatched(tc.ri)) + }) + } +} diff --git a/pkg/policies/testdata/scoped_signed.yaml b/pkg/policies/testdata/scoped_signed.yaml new file mode 100644 index 000000000..4145c2280 --- /dev/null +++ b/pkg/policies/testdata/scoped_signed.yaml @@ -0,0 +1,19 @@ +apiVersion: chainloop.dev/v1 +kind: Policy +metadata: + name: trusted-binaries-signed +spec: + inputs: + - name: ignored_paths + policies: + - kind: SBOM_SPDX_JSON + embedded: | + package main + + import rego.v1 + + result := { + "skipped": true, + "violations": [], + "skip_reason": "", + } diff --git a/pkg/policies/testdata/scoped_vendor_keys.yaml b/pkg/policies/testdata/scoped_vendor_keys.yaml new file mode 100644 index 000000000..088928760 --- /dev/null +++ b/pkg/policies/testdata/scoped_vendor_keys.yaml @@ -0,0 +1,19 @@ +apiVersion: chainloop.dev/v1 +kind: Policy +metadata: + name: trusted-binaries-vendor-keys +spec: + inputs: + - name: ignored_paths + policies: + - kind: SBOM_SPDX_JSON + embedded: | + package main + + import rego.v1 + + result := { + "skipped": true, + "violations": [], + "skip_reason": "", + }