Skip to content

Commit 2ab706f

Browse files
committed
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: [<policy>:]<input>=<file>[:<column>] 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 <javier@chainloop.dev> Chainloop-Trace-Sessions: 21d09b3d-bdcb-4e52-9aca-56aa3c1b5139, 92f34c12-d29d-4d4a-897a-4afea9b1ee86
1 parent a7c7890 commit 2ab706f

13 files changed

Lines changed: 628 additions & 63 deletions

app/cli/cmd/attestation_add.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,12 @@ func newAttestationAddCmd() *cobra.Command {
7272
# 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).
7373
# The :column suffix selects the column; it defaults to the input name when omitted. The file is also recorded as EVIDENCE.
7474
chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \
75-
--policy-input-from-file ignored_paths=exception.csv:Path`,
75+
--policy-input-from-file ignored_paths=exception.csv:Path
76+
77+
# Scope an input to a specific policy with a <policy>: prefix so it only applies to that policy attachment.
78+
chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \
79+
--policy-input-from-file trusted-binaries-signed:ignored_paths=exception.csv:Path \
80+
--policy-input-from-file trusted-binaries-vendor-keys:third_party_paths=exception.csv:Path`,
7681
RunE: func(cmd *cobra.Command, _ []string) error {
7782
a, err := action.NewAttestationAdd(
7883
&action.AttestationAddOpts{
@@ -159,7 +164,7 @@ func newAttestationAddCmd() *cobra.Command {
159164
flagAttestationID(cmd)
160165
cmd.Flags().StringVar(&kind, "kind", "", fmt.Sprintf("kind of the material to be recorded: %q", schemaapi.ListAvailableMaterialKind()))
161166
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)")
162-
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 <input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.")
167+
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 [<policy>:]<input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); an optional <policy>: prefix scopes the input to a single policy (matched by name or ref), otherwise it applies to every declaring policy; <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.")
163168

164169
// Optional OCI registry credentials
165170
cmd.Flags().StringVar(&registryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName))

app/cli/documentation/cli-reference.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ Feed a policy input from a column of a CSV/JSON file (e.g. the ignored_paths exc
249249
The :column suffix selects the column; it defaults to the input name when omitted. The file is also recorded as EVIDENCE.
250250
chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \
251251
--policy-input-from-file ignored_paths=exception.csv:Path
252+
253+
Scope an input to a specific policy with a <policy>: prefix so it only applies to that policy attachment.
254+
chainloop attestation add --name sigcheck --value sigcheckResult.csv --kind SYSINTERNALS_SIGCHECK \
255+
--policy-input-from-file trusted-binaries-signed:ignored_paths=exception.csv:Path \
256+
--policy-input-from-file trusted-binaries-vendor-keys:third_party_paths=exception.csv:Path
252257
```
253258

254259
Options
@@ -260,7 +265,7 @@ Options
260265
--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"]
261266
--name string name of the material as shown in the contract
262267
--no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)
263-
--policy-input-from-file stringArray feed a policy input from a column of a CSV or JSON file, in the format <input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.
268+
--policy-input-from-file stringArray feed a policy input from a column of a CSV or JSON file, in the format [<policy>:]<input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); an optional <policy>: prefix scopes the input to a single policy (matched by name or ref), otherwise it applies to every declaring policy; <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.
264269
--registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD)
265270
--registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER)
266271
--registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME)

app/cli/pkg/action/attestation_add.go

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2828
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
2929
"github.com/chainloop-dev/chainloop/pkg/casclient"
30+
"github.com/chainloop-dev/chainloop/pkg/policies"
3031
"google.golang.org/grpc"
3132
)
3233

@@ -181,39 +182,55 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
181182
// runtimeInputAddOpts wraps the runtime inputs as crafter add options, or
182183
// returns nil when there are none. Defined at package scope so it can name the
183184
// crafter package type (the Run method shadows it with a local variable).
184-
func runtimeInputAddOpts(runtimeInputs map[string]string) []crafter.AddOpt {
185-
if len(runtimeInputs) == 0 {
185+
func runtimeInputAddOpts(runtimeInputs *policies.RuntimeInputs) []crafter.AddOpt {
186+
if runtimeInputs == nil || (len(runtimeInputs.Global) == 0 && len(runtimeInputs.Scoped) == 0) {
186187
return nil
187188
}
188189
return []crafter.AddOpt{crafter.WithRuntimeInputs(runtimeInputs)}
189190
}
190191

191-
// buildRuntimeInputs reads each policy input file and returns a map of policy
192-
// input name to its extracted values, ready to be merged onto contract
193-
// arguments. Values are newline-joined, matching the engine's existing
194-
// multi-value encoding (it splits inputs back on newlines and commas). As with
195-
// contract-declared arguments, individual values must not embed those
196-
// delimiters; path globs, the intended use, never do.
197-
func buildRuntimeInputs(policyInputFiles []*PolicyInputFromFile) (map[string]string, error) {
192+
// buildRuntimeInputs reads each policy input file and returns the extracted
193+
// values grouped for the policy engine: unscoped entries under Global and
194+
// policy-scoped entries under Scoped[policy]. Values are newline-joined,
195+
// matching the engine's existing multi-value encoding (it splits inputs back on
196+
// newlines and commas). As with contract-declared arguments, individual values
197+
// must not embed those delimiters; path globs, the intended use, never do.
198+
func buildRuntimeInputs(policyInputFiles []*PolicyInputFromFile) (*policies.RuntimeInputs, error) {
198199
if len(policyInputFiles) == 0 {
199200
return nil, nil
200201
}
201202

202-
runtimeInputs := make(map[string]string, len(policyInputFiles))
203+
ri := &policies.RuntimeInputs{
204+
Global: map[string]string{},
205+
Scoped: map[string]map[string]string{},
206+
}
203207
for _, pif := range policyInputFiles {
204208
values, err := ExtractColumnValues(pif.File, pif.Column)
205209
if err != nil {
206210
return nil, fmt.Errorf("extracting %q from %q: %w", pif.Column, pif.File, err)
207211
}
212+
213+
// Unscoped entries go to Global; policy-scoped entries to their own
214+
// Scoped[policy] map. Because global and scoped values live in separate
215+
// maps, they never collide here even when they share an input name;
216+
// forPolicy is what later merges a policy's scoped values over Global.
217+
target := ri.Global
218+
if pif.Policy != "" {
219+
if ri.Scoped[pif.Policy] == nil {
220+
ri.Scoped[pif.Policy] = map[string]string{}
221+
}
222+
target = ri.Scoped[pif.Policy]
223+
}
224+
208225
joined := strings.Join(values, "\n")
209-
if existing := runtimeInputs[pif.Input]; existing != "" {
210-
runtimeInputs[pif.Input] = existing + "\n" + joined
226+
if existing := target[pif.Input]; existing != "" {
227+
target[pif.Input] = existing + "\n" + joined
211228
} else {
212-
runtimeInputs[pif.Input] = joined
229+
target[pif.Input] = joined
213230
}
214231
}
215232

216-
return runtimeInputs, nil
233+
return ri, nil
217234
}
218235

219236
// addPolicyInputEvidence adds each policy input file as an EVIDENCE material,

app/cli/pkg/action/attestation_add_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
package action
1717

1818
import (
19+
"os"
20+
"path/filepath"
1921
"regexp"
2022
"testing"
2123

2224
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2325
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
26+
"github.com/chainloop-dev/chainloop/pkg/policies"
2427
"github.com/stretchr/testify/assert"
28+
"github.com/stretchr/testify/require"
2529
)
2630

2731
// materialNameRe mirrors the DNS-1123-style constraint enforced on material
@@ -112,3 +116,55 @@ func TestBuildRuntimeInputsNil(t *testing.T) {
112116
assert.NoError(t, err)
113117
assert.Nil(t, got)
114118
}
119+
120+
func TestBuildRuntimeInputs(t *testing.T) {
121+
dir := t.TempDir()
122+
// A single file with two columns reused across inputs.
123+
path := filepath.Join(dir, "exception.csv")
124+
require.NoError(t, os.WriteFile(path, []byte("Path,Extra\na.dll,x\nb.dll,y\n"), 0600))
125+
126+
t.Run("unscoped inputs land in Global", func(t *testing.T) {
127+
got, err := buildRuntimeInputs([]*PolicyInputFromFile{
128+
{Input: "ignored_paths", Column: "Path", File: path},
129+
})
130+
require.NoError(t, err)
131+
assert.Equal(t, &policies.RuntimeInputs{
132+
Global: map[string]string{"ignored_paths": "a.dll\nb.dll"},
133+
Scoped: map[string]map[string]string{},
134+
}, got)
135+
})
136+
137+
t.Run("scoped inputs land under their policy", func(t *testing.T) {
138+
got, err := buildRuntimeInputs([]*PolicyInputFromFile{
139+
{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: path},
140+
{Policy: "trusted-binaries-vendor-keys", Input: "third_party_paths", Column: "Path", File: path},
141+
})
142+
require.NoError(t, err)
143+
assert.Equal(t, &policies.RuntimeInputs{
144+
Global: map[string]string{},
145+
Scoped: map[string]map[string]string{
146+
"trusted-binaries-signed": {"ignored_paths": "a.dll\nb.dll"},
147+
"trusted-binaries-vendor-keys": {"third_party_paths": "a.dll\nb.dll"},
148+
},
149+
}, got)
150+
})
151+
152+
t.Run("repeated scope+input merges additively", func(t *testing.T) {
153+
got, err := buildRuntimeInputs([]*PolicyInputFromFile{
154+
{Policy: "p", Input: "ignored_paths", Column: "Path", File: path},
155+
{Policy: "p", Input: "ignored_paths", Column: "Extra", File: path},
156+
})
157+
require.NoError(t, err)
158+
assert.Equal(t, map[string]string{"ignored_paths": "a.dll\nb.dll\nx\ny"}, got.Scoped["p"])
159+
})
160+
161+
t.Run("global and scoped coexist", func(t *testing.T) {
162+
got, err := buildRuntimeInputs([]*PolicyInputFromFile{
163+
{Input: "ignored_paths", Column: "Path", File: path},
164+
{Policy: "p", Input: "ignored_paths", Column: "Extra", File: path},
165+
})
166+
require.NoError(t, err)
167+
assert.Equal(t, map[string]string{"ignored_paths": "a.dll\nb.dll"}, got.Global)
168+
assert.Equal(t, map[string]string{"ignored_paths": "x\ny"}, got.Scoped["p"])
169+
})
170+
}

app/cli/pkg/action/policy_input_file.go

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import (
3030
// PolicyInputFromFile describes a single --policy-input-from-file flag value: a
3131
// policy input name fed from a named column of a CSV or JSON file.
3232
type PolicyInputFromFile struct {
33+
// Policy optionally scopes the input to a specific policy (its name or ref).
34+
// Empty means the input is global and applies to every declaring policy.
35+
Policy string
3336
// Input is the destination policy input name (e.g. "ignored_paths").
3437
Input string
3538
// Column is the file column/field to extract. Defaults to Input.
@@ -38,20 +41,53 @@ type PolicyInputFromFile struct {
3841
File string
3942
}
4043

44+
// scopeDelimiter separates an optional policy scope from the input name on the
45+
// left-hand side of a --policy-input-from-file value. It is ":" (shell-inert in
46+
// bash, sh and zsh). A policy ref can itself contain ":" (in a "://" scheme or
47+
// an "@sha256:" digest) while an input name never can, so the scope is split off
48+
// at the *last* ":" of the left-hand side.
49+
const scopeDelimiter = ":"
50+
51+
// digestScheme is the digest prefix used in versioned policy refs
52+
// (<policy>@sha256:<digest>). Used to detect a versioned scope whose input name
53+
// was omitted, which would otherwise be mistaken for the digest.
54+
const digestScheme = "@sha256"
55+
4156
// ParsePolicyInputFromFile parses a single flag value of the form
42-
// "<input>=<file>[:<column>]". The column is optional and defaults to the input
43-
// name. A column is always a single, top-level field/header name — never a path
44-
// or a nested key. The column is the segment after the last ":"; since a column
45-
// name never contains a path separator, a trailing ":<...>" whose ":" belongs to
46-
// the file (a Windows drive letter like C:\data\... or a URL scheme like
47-
// https://) is not mistaken for a column.
57+
// "[<policy>:]<input>=<file>[:<column>]". The optional "<policy>:" prefix scopes
58+
// the input to a single policy (matched against its name or ref); without it the
59+
// input is global. Because a policy ref may itself contain ":" but an input name
60+
// never does, the scope is taken as everything before the *last* ":" on the
61+
// left of "=". The column is optional and defaults to the input name. A column
62+
// is always a single, top-level field/header name — never a path or a nested
63+
// key. The column is the segment after the last ":"; since a column name never
64+
// contains a path separator, a trailing ":<...>" whose ":" belongs to the file
65+
// (a Windows drive letter like C:\data\... or a URL scheme like https://) is not
66+
// mistaken for a column.
4867
func ParsePolicyInputFromFile(raw string) (*PolicyInputFromFile, error) {
49-
input, rhs, found := strings.Cut(raw, "=")
68+
lhs, rhs, found := strings.Cut(raw, "=")
5069
if !found {
51-
return nil, fmt.Errorf("invalid --policy-input-from-file %q: expected <input>=<file>[:<column>]", raw)
70+
return nil, fmt.Errorf("invalid --policy-input-from-file %q: expected [<policy>:]<input>=<file>[:<column>]", raw)
71+
}
72+
73+
// Split off the optional "<policy>:" scope prefix at the last ":": a policy
74+
// ref may contain colons (scheme, digest) but the input name never does.
75+
var policy, input string
76+
if i := strings.LastIndex(lhs, scopeDelimiter); i >= 0 {
77+
policy = strings.TrimSpace(lhs[:i])
78+
input = strings.TrimSpace(lhs[i+1:])
79+
if policy == "" {
80+
return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing policy scope before %q", raw, scopeDelimiter)
81+
}
82+
// A bare "<policy>@sha256:<digest>" (no input) would be mis-split into
83+
// policy "<policy>@sha256" and input "<digest>"; reject it with guidance.
84+
if strings.HasSuffix(policy, digestScheme) {
85+
return nil, fmt.Errorf("invalid --policy-input-from-file %q: versioned policy scope is missing an input name; expected <policy>@sha256:<digest>:<input>=<file>", raw)
86+
}
87+
} else {
88+
input = strings.TrimSpace(lhs)
5289
}
5390

54-
input = strings.TrimSpace(input)
5591
rhs = strings.TrimSpace(rhs)
5692
if input == "" {
5793
return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing input name", raw)
@@ -75,7 +111,7 @@ func ParsePolicyInputFromFile(raw string) (*PolicyInputFromFile, error) {
75111
return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing file path", raw)
76112
}
77113

78-
return &PolicyInputFromFile{Input: input, Column: column, File: file}, nil
114+
return &PolicyInputFromFile{Policy: policy, Input: input, Column: column, File: file}, nil
79115
}
80116

81117
// ExtractColumnValues reads the given CSV or JSON file and returns the values of

app/cli/pkg/action/policy_input_file_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,46 @@ func TestParsePolicyInputFromFile(t *testing.T) {
6161
raw: "versions=exception.csv:Product Version",
6262
want: &PolicyInputFromFile{Input: "versions", Column: "Product Version", File: "exception.csv"},
6363
},
64+
{
65+
name: "policy-scoped input",
66+
raw: "trusted-binaries-signed:ignored_paths=exception.csv:Path",
67+
want: &PolicyInputFromFile{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: "exception.csv"},
68+
},
69+
{
70+
name: "policy-scoped input defaults the column to the input",
71+
raw: "trusted-binaries-signed:ignored_paths=exception.csv",
72+
want: &PolicyInputFromFile{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "ignored_paths", File: "exception.csv"},
73+
},
74+
{
75+
name: "policy-scoped input pinned to a version",
76+
raw: "trusted-binaries-signed@sha256:deadbeef:ignored_paths=exception.csv:Path",
77+
want: &PolicyInputFromFile{Policy: "trusted-binaries-signed@sha256:deadbeef", Input: "ignored_paths", Column: "Path", File: "exception.csv"},
78+
},
79+
{
80+
name: "provider-style scope keeps its colon",
81+
raw: "builtin:trusted-binaries-signed:ignored_paths=exception.csv:Path",
82+
want: &PolicyInputFromFile{Policy: "builtin:trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: "exception.csv"},
83+
},
84+
{
85+
name: "policy scope surrounding whitespace trimmed",
86+
raw: " trusted-binaries-signed : ignored_paths = exception.csv : Path ",
87+
want: &PolicyInputFromFile{Policy: "trusted-binaries-signed", Input: "ignored_paths", Column: "Path", File: "exception.csv"},
88+
},
89+
{
90+
name: "empty policy scope",
91+
raw: ":ignored_paths=exception.csv",
92+
wantErr: true,
93+
},
94+
{
95+
name: "policy scope with empty input",
96+
raw: "trusted-binaries-signed:=exception.csv",
97+
wantErr: true,
98+
},
99+
{
100+
name: "versioned scope missing an input name",
101+
raw: "trusted-binaries-signed@sha256:deadbeef=exception.csv",
102+
wantErr: true,
103+
},
64104
{
65105
name: "surrounding whitespace trimmed",
66106
raw: " ignored_paths = exception.csv : Path ",

pkg/attestation/crafter/crafter.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -567,14 +567,15 @@ type AddOpt func(*addOpts)
567567
type addOpts struct {
568568
// runtimeInputs holds policy input values supplied at runtime (e.g. sourced
569569
// from a file via --policy-input-from-file). They are merged additively onto
570-
// the contract arguments when evaluating the standalone material policies.
571-
runtimeInputs map[string]string
570+
// the contract arguments when evaluating the standalone material policies,
571+
// either globally or scoped to a specific policy.
572+
runtimeInputs *policies.RuntimeInputs
572573
}
573574

574575
// WithRuntimeInputs supplies policy input values that are merged additively onto
575576
// the contract arguments when evaluating the standalone material policies for
576577
// this add. Used by --policy-input-from-file.
577-
func WithRuntimeInputs(inputs map[string]string) AddOpt {
578+
func WithRuntimeInputs(inputs *policies.RuntimeInputs) AddOpt {
578579
return func(o *addOpts) {
579580
o.runtimeInputs = inputs
580581
}

0 commit comments

Comments
 (0)