Skip to content

Commit 784ee26

Browse files
committed
feat(cli): inject policy inputs from CSV/JSON files at attestation add
Add a repeatable --policy-input-from-file <input>[:<column>]=<file> flag to `chainloop attestation add` that extracts... The source file is recorded as an EVIDENCE material, cross-linked to the evaluated material via a generic chainloop.m... CSV parsing reuses the existing sigcheck parser; JSON accepts a string array, an array of objects, or an object mappi... Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev> Chainloop-Trace-Sessions: fe524d3a-ae31-482c-8675-45df3bfe4d81
1 parent 2058563 commit 784ee26

15 files changed

Lines changed: 911 additions & 39 deletions

File tree

app/cli/cmd/attestation_add.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func newAttestationAddCmd() *cobra.Command {
3939
var artifactCASConn *grpc.ClientConn
4040
var annotationsFlag []string
4141
var noStrictValidation bool
42+
var policyInputFromFileFlag []string
4243

4344
// OCI registry credentials can be passed as flags or environment variables
4445
var registryServer, registryUsername, registryPassword string
@@ -91,6 +92,13 @@ func newAttestationAddCmd() *cobra.Command {
9192
return err
9293
}
9394

95+
// Parse and resolve the policy input files (column -> policy input).
96+
// Done once here; the resolved local paths are reused across retries.
97+
policyInputFiles, err := resolvePolicyInputFiles(policyInputFromFileFlag)
98+
if err != nil {
99+
return err
100+
}
101+
94102
// In some cases, the attestation state is stored remotely. To control concurrency we use
95103
// optimistic locking. We retry the operation if the state has changed since we last read it.
96104
return runWithBackoffRetry(
@@ -110,7 +118,7 @@ func newAttestationAddCmd() *cobra.Command {
110118
}
111119
}
112120
// TODO: take the material output and show render it
113-
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations)
121+
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations, policyInputFiles)
114122
if err != nil {
115123
return err
116124
}
@@ -146,6 +154,7 @@ func newAttestationAddCmd() *cobra.Command {
146154
flagAttestationID(cmd)
147155
cmd.Flags().StringVar(&kind, "kind", "", fmt.Sprintf("kind of the material to be recorded: %q", schemaapi.ListAvailableMaterialKind()))
148156
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)")
157+
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.")
149158

150159
// Optional OCI registry credentials
151160
cmd.Flags().StringVar(&registryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName))
@@ -167,6 +176,38 @@ func newAttestationAddCmd() *cobra.Command {
167176
return cmd
168177
}
169178

179+
// resolvePolicyInputFiles parses each --policy-input-from-file value and
180+
// resolves its file reference to a local path (downloading URLs to a temporary
181+
// file, mirroring how --value is handled).
182+
func resolvePolicyInputFiles(raw []string) ([]*action.PolicyInputFromFile, error) {
183+
if len(raw) == 0 {
184+
return nil, nil
185+
}
186+
187+
result := make([]*action.PolicyInputFromFile, 0, len(raw))
188+
for _, r := range raw {
189+
pif, err := action.ParsePolicyInputFromFile(r)
190+
if err != nil {
191+
return nil, err
192+
}
193+
194+
path, err := resourceloader.GetPathForResource(pif.File)
195+
if err != nil {
196+
var uerr *resourceloader.UnrecognizedSchemeError
197+
if errors.As(err, &uerr) {
198+
path = pif.File
199+
} else {
200+
return nil, fmt.Errorf("loading policy input file: %w", err)
201+
}
202+
}
203+
pif.File = path
204+
205+
result = append(result, pif)
206+
}
207+
208+
return result, nil
209+
}
210+
170211
// displayMaterialInfo prints the material information in a table format.
171212
func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluations []*action.PolicyEvaluation) error {
172213
if status == nil {

app/cli/documentation/cli-reference.mdx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,17 @@ chainloop attestation add --value https://example.com/sbom.json
249249
Options
250250

251251
```
252-
--annotation strings additional annotation in the format of key=value
253-
--attestation-id string Unique identifier of the in-progress attestation
254-
-h, --help help for add
255-
--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"]
256-
--name string name of the material as shown in the contract
257-
--no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)
258-
--registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD)
259-
--registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER)
260-
--registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME)
261-
--value string value to be recorded
252+
--annotation strings additional annotation in the format of key=value
253+
--attestation-id string Unique identifier of the in-progress attestation
254+
-h, --help help for add
255+
--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"]
256+
--name string name of the material as shown in the contract
257+
--no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)
258+
--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.
259+
--registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD)
260+
--registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER)
261+
--registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME)
262+
--value string value to be recorded
262263
```
263264

264265
Options inherited from parent commands

app/cli/pkg/action/attestation_add.go

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ import (
1919
"context"
2020
"errors"
2121
"fmt"
22+
"strings"
2223

2324
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
25+
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2426
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
2527
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
28+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
2629
"github.com/chainloop-dev/chainloop/pkg/casclient"
2730
"google.golang.org/grpc"
2831
)
@@ -77,13 +80,20 @@ func NewAttestationAdd(cfg *AttestationAddOpts) (*AttestationAdd, error) {
7780

7881
var ErrAttestationNotInitialized = errors.New("attestation not yet initialized")
7982

80-
func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string) (*AttestationStatusMaterial, error) {
83+
func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string, policyInputFiles []*PolicyInputFromFile) (*AttestationStatusMaterial, error) {
8184
// initialize the crafter. If attestation-id is provided we assume the attestation is performed using remote state
8285
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
8386
if err != nil {
8487
return nil, fmt.Errorf("failed to load crafter: %w", err)
8588
}
8689

90+
// Resolve runtime policy inputs from the provided files before adding the
91+
// material, so a malformed file aborts the add early.
92+
runtimeInputs, err := buildRuntimeInputs(policyInputFiles)
93+
if err != nil {
94+
return nil, err
95+
}
96+
8797
if initialized, err := crafter.AlreadyInitialized(ctx, attestationID); err != nil {
8898
return nil, fmt.Errorf("checking if attestation is already initialized: %w", err)
8999
} else if !initialized {
@@ -120,10 +130,12 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
120130
// 2. If materialName is not empty, check if the material is in the contract. If it is, add material from contract
121131
// 2.1. If materialType is empty, try to guess the material kind with auto-detected kind and materialName
122132
// 3. If materialType is not empty, add material contract free with materialType and materialName
133+
addOpts := runtimeInputAddOpts(runtimeInputs)
134+
123135
var mt *api.Attestation_Material
124136
switch {
125137
case materialName == "" && materialType == "":
126-
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, "", materialValue, casBackend, annotations)
138+
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, "", materialValue, casBackend, annotations, addOpts...)
127139
if err != nil {
128140
return nil, fmt.Errorf("adding material: %w", err)
129141
}
@@ -132,26 +144,32 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
132144
switch {
133145
// If the material is in the contract, add it from the contract
134146
case crafter.IsMaterialInContract(materialName):
135-
mt, err = crafter.AddMaterialFromContract(ctx, attestationID, materialName, materialValue, casBackend, annotations)
147+
mt, err = crafter.AddMaterialFromContract(ctx, attestationID, materialName, materialValue, casBackend, annotations, addOpts...)
136148
// If the material is not in the contract and the materialType is not provided, add material contract free with auto-detected kind, guessing the kind
137149
case materialType == "":
138-
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, materialName, materialValue, casBackend, annotations)
150+
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, materialName, materialValue, casBackend, annotations, addOpts...)
139151
if err != nil {
140152
return nil, fmt.Errorf("adding material: %w", err)
141153
}
142154
action.Logger.Info().Str("kind", mt.MaterialType.String()).Msg("material kind detected")
143155
// If the material is not in the contract and has a materialType, add material contract free with the provided materialType
144156
default:
145-
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations)
157+
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations, addOpts...)
146158
}
147159
default:
148-
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations)
160+
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations, addOpts...)
149161
}
150162

151163
if err != nil {
152164
return nil, fmt.Errorf("adding material: %w", err)
153165
}
154166

167+
// Record each source file as an EVIDENCE material, cross-linked to the
168+
// evaluated material so the exemption set itself is attested.
169+
if err := action.addPolicyInputEvidence(ctx, crafter, attestationID, mt.GetId(), policyInputFiles, casBackend); err != nil {
170+
return nil, fmt.Errorf("recording policy input evidence: %w", err)
171+
}
172+
155173
materialResult, err := attMaterialToAction(mt)
156174
if err != nil {
157175
return nil, fmt.Errorf("converting material to action: %w", err)
@@ -160,6 +178,88 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
160178
return materialResult, nil
161179
}
162180

181+
// runtimeInputAddOpts wraps the runtime inputs as crafter add options, or
182+
// returns nil when there are none. Defined at package scope so it can name the
183+
// 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 {
186+
return nil
187+
}
188+
return []crafter.AddOpt{crafter.WithRuntimeInputs(runtimeInputs)}
189+
}
190+
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) {
198+
if len(policyInputFiles) == 0 {
199+
return nil, nil
200+
}
201+
202+
runtimeInputs := make(map[string]string, len(policyInputFiles))
203+
for _, pif := range policyInputFiles {
204+
values, err := ExtractColumnValues(pif.File, pif.Column)
205+
if err != nil {
206+
return nil, fmt.Errorf("extracting %q from %q: %w", pif.Column, pif.File, err)
207+
}
208+
joined := strings.Join(values, "\n")
209+
if existing := runtimeInputs[pif.Input]; existing != "" {
210+
runtimeInputs[pif.Input] = existing + "\n" + joined
211+
} else {
212+
runtimeInputs[pif.Input] = joined
213+
}
214+
}
215+
216+
return runtimeInputs, nil
217+
}
218+
219+
// addPolicyInputEvidence adds each policy input file as an EVIDENCE material,
220+
// linked back to the evaluated material via the chainloop.material.references
221+
// annotation. The evidence material name is derived as "<material>-<input>";
222+
// when the same input is fed by more than one file, a "-<n>" suffix keeps the
223+
// names unique so no evidence record is silently overwritten.
224+
func (action *AttestationAdd) addPolicyInputEvidence(ctx context.Context, c *crafter.Crafter, attestationID, materialName string, policyInputFiles []*PolicyInputFromFile, casBackend *casclient.CASBackend) error {
225+
names := policyInputEvidenceNames(materialName, policyInputFiles)
226+
for i, pif := range policyInputFiles {
227+
annotations := map[string]string{
228+
materials.AnnotationMaterialReferences: materialName,
229+
}
230+
231+
if _, err := c.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_EVIDENCE.String(), names[i], pif.File, casBackend, annotations); err != nil {
232+
return fmt.Errorf("adding evidence material %q: %w", names[i], err)
233+
}
234+
}
235+
236+
return nil
237+
}
238+
239+
// policyInputEvidenceNames returns the evidence material name for each policy
240+
// input file, in order. Names are "<material>-<input>"; when the same input is
241+
// fed by more than one file, a "-<n>" suffix keeps them unique so no evidence
242+
// record is silently overwritten in the attestation.
243+
func policyInputEvidenceNames(materialName string, policyInputFiles []*PolicyInputFromFile) []string {
244+
inputCount := make(map[string]int, len(policyInputFiles))
245+
for _, pif := range policyInputFiles {
246+
inputCount[pif.Input]++
247+
}
248+
249+
names := make([]string, len(policyInputFiles))
250+
seen := make(map[string]int, len(policyInputFiles))
251+
for i, pif := range policyInputFiles {
252+
name := fmt.Sprintf("%s-%s", materialName, pif.Input)
253+
if inputCount[pif.Input] > 1 {
254+
seen[pif.Input]++
255+
name = fmt.Sprintf("%s-%d", name, seen[pif.Input])
256+
}
257+
names[i] = name
258+
}
259+
260+
return names
261+
}
262+
163263
// GetPolicyEvaluations is a Wrapper around the getPolicyEvaluations
164264
func (action *AttestationAdd) GetPolicyEvaluations(ctx context.Context, attestationID string) (map[string][]*PolicyEvaluation, error) {
165265
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package action
17+
18+
import (
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
)
23+
24+
func TestPolicyInputEvidenceNames(t *testing.T) {
25+
testCases := []struct {
26+
name string
27+
materialName string
28+
files []*PolicyInputFromFile
29+
want []string
30+
}{
31+
{
32+
name: "single input keeps the plain name",
33+
materialName: "binaries",
34+
files: []*PolicyInputFromFile{{Input: "ignored_paths"}},
35+
want: []string{"binaries-ignored_paths"},
36+
},
37+
{
38+
name: "distinct inputs are not suffixed",
39+
materialName: "binaries",
40+
files: []*PolicyInputFromFile{{Input: "ignored_paths"}, {Input: "paths"}},
41+
want: []string{"binaries-ignored_paths", "binaries-paths"},
42+
},
43+
{
44+
name: "same input fed by multiple files is disambiguated",
45+
materialName: "binaries",
46+
files: []*PolicyInputFromFile{{Input: "ignored_paths"}, {Input: "ignored_paths"}},
47+
want: []string{"binaries-ignored_paths-1", "binaries-ignored_paths-2"},
48+
},
49+
}
50+
51+
for _, tc := range testCases {
52+
t.Run(tc.name, func(t *testing.T) {
53+
got := policyInputEvidenceNames(tc.materialName, tc.files)
54+
assert.Equal(t, tc.want, got)
55+
})
56+
}
57+
}
58+
59+
func TestBuildRuntimeInputsNil(t *testing.T) {
60+
got, err := buildRuntimeInputs(nil)
61+
assert.NoError(t, err)
62+
assert.Nil(t, got)
63+
}

0 commit comments

Comments
 (0)