Skip to content

Commit 47dd5a3

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 47dd5a3

20 files changed

Lines changed: 1087 additions & 85 deletions

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 {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 cmd
17+
18+
import (
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestResolvePolicyInputFiles(t *testing.T) {
29+
testCases := []struct {
30+
name string
31+
raw []string
32+
want []*action.PolicyInputFromFile
33+
wantNil bool
34+
wantErr bool
35+
}{
36+
{
37+
name: "nil input returns nil",
38+
raw: nil,
39+
wantNil: true,
40+
},
41+
{
42+
name: "empty input returns nil",
43+
raw: []string{},
44+
wantNil: true,
45+
},
46+
{
47+
name: "malformed value propagates the parse error",
48+
raw: []string{"missing-equals"},
49+
wantErr: true,
50+
},
51+
{
52+
name: "scheme-less missing path keeps the original file value",
53+
raw: []string{"ignored_paths=/does/not/exist.csv:Path"},
54+
want: []*action.PolicyInputFromFile{
55+
{Input: "ignored_paths", Column: "Path", File: "/does/not/exist.csv"},
56+
},
57+
},
58+
{
59+
name: "multiple entries keep order and default the column",
60+
raw: []string{
61+
"ignored_paths=/no/exist1.csv",
62+
"paths=/no/exist2.json:Glob",
63+
},
64+
want: []*action.PolicyInputFromFile{
65+
{Input: "ignored_paths", Column: "ignored_paths", File: "/no/exist1.csv"},
66+
{Input: "paths", Column: "Glob", File: "/no/exist2.json"},
67+
},
68+
},
69+
{
70+
name: "unresolvable env reference errors",
71+
raw: []string{"ignored_paths=env://CHAINLOOP_TEST_DEFINITELY_UNSET_VAR"},
72+
wantErr: true,
73+
},
74+
}
75+
76+
for _, tc := range testCases {
77+
t.Run(tc.name, func(t *testing.T) {
78+
got, err := resolvePolicyInputFiles(tc.raw)
79+
if tc.wantErr {
80+
assert.Error(t, err)
81+
return
82+
}
83+
require.NoError(t, err)
84+
if tc.wantNil {
85+
assert.Nil(t, got)
86+
return
87+
}
88+
assert.Equal(t, tc.want, got)
89+
})
90+
}
91+
}
92+
93+
// TestResolvePolicyInputFilesExistingFile checks that an on-disk file is
94+
// resolved to its own path (no temporary copy).
95+
func TestResolvePolicyInputFilesExistingFile(t *testing.T) {
96+
dir := t.TempDir()
97+
path := filepath.Join(dir, "exception.csv")
98+
require.NoError(t, os.WriteFile(path, []byte("Path\nc:\\a.dll\n"), 0600))
99+
100+
got, err := resolvePolicyInputFiles([]string{"ignored_paths=" + path + ":Path"})
101+
require.NoError(t, err)
102+
require.Len(t, got, 1)
103+
assert.Equal(t, &action.PolicyInputFromFile{Input: "ignored_paths", Column: "Path", File: path}, got[0])
104+
}
105+
106+
// TestResolvePolicyInputFilesResolvesEnv checks that a non-file reference (here
107+
// env://) is downloaded to a local temporary path that differs from the
108+
// original reference.
109+
func TestResolvePolicyInputFilesResolvesEnv(t *testing.T) {
110+
t.Setenv("CHAINLOOP_TEST_POLICY_INPUT", `["c:\\a.dll"]`)
111+
112+
got, err := resolvePolicyInputFiles([]string{"ignored_paths=env://CHAINLOOP_TEST_POLICY_INPUT"})
113+
require.NoError(t, err)
114+
require.Len(t, got, 1)
115+
116+
assert.Equal(t, "ignored_paths", got[0].Input)
117+
assert.Equal(t, "ignored_paths", got[0].Column)
118+
// The env reference is materialized to a real local file.
119+
assert.NotEqual(t, "env://CHAINLOOP_TEST_POLICY_INPUT", got[0].File)
120+
content, err := os.ReadFile(got[0].File)
121+
require.NoError(t, err)
122+
assert.Equal(t, `["c:\\a.dll"]`, string(content))
123+
}

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

0 commit comments

Comments
 (0)