Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions app/cli/cmd/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func newAttestationAddCmd() *cobra.Command {
var artifactCASConn *grpc.ClientConn
var annotationsFlag []string
var noStrictValidation bool
var policyInputFromFileFlag []string

// OCI registry credentials can be passed as flags or environment variables
var registryServer, registryUsername, registryPassword string
Expand Down Expand Up @@ -66,7 +67,12 @@ func newAttestationAddCmd() *cobra.Command {
chainloop attestation add --value <material-value>

# Add a material by also providing a URL pointing to the material. It will be downloaded to a temporary folder first
chainloop attestation add --value https://example.com/sbom.json`,
chainloop attestation add --value https://example.com/sbom.json

# 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`,
RunE: func(cmd *cobra.Command, _ []string) error {
a, err := action.NewAttestationAdd(
&action.AttestationAddOpts{
Expand All @@ -91,6 +97,13 @@ func newAttestationAddCmd() *cobra.Command {
return err
}

// Parse and resolve the policy input files (column -> policy input).
// Done once here; the resolved local paths are reused across retries.
policyInputFiles, err := resolvePolicyInputFiles(policyInputFromFileFlag)
if err != nil {
return err
}

// In some cases, the attestation state is stored remotely. To control concurrency we use
// optimistic locking. We retry the operation if the state has changed since we last read it.
return runWithBackoffRetry(
Expand All @@ -110,7 +123,7 @@ func newAttestationAddCmd() *cobra.Command {
}
}
// TODO: take the material output and show render it
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations)
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations, policyInputFiles)
if err != nil {
return err
}
Expand Down Expand Up @@ -146,6 +159,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 <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.")

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

// resolvePolicyInputFiles parses each --policy-input-from-file value and
// resolves its file reference to a local path (downloading URLs to a temporary
// file, mirroring how --value is handled).
func resolvePolicyInputFiles(raw []string) ([]*action.PolicyInputFromFile, error) {
if len(raw) == 0 {
return nil, nil
}

result := make([]*action.PolicyInputFromFile, 0, len(raw))
for _, r := range raw {
pif, err := action.ParsePolicyInputFromFile(r)
if err != nil {
return nil, err
}

path, err := resourceloader.GetPathForResource(pif.File)
if err != nil {
var uerr *resourceloader.UnrecognizedSchemeError
if errors.As(err, &uerr) {
path = pif.File
} else {
return nil, fmt.Errorf("loading policy input file: %w", err)
}
}
pif.File = path

result = append(result, pif)
}

return result, nil
}

// displayMaterialInfo prints the material information in a table format.
func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluations []*action.PolicyEvaluation) error {
if status == nil {
Expand Down
123 changes: 123 additions & 0 deletions app/cli/cmd/policy_input_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//
// 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 cmd

import (
"os"
"path/filepath"
"testing"

"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolvePolicyInputFiles(t *testing.T) {
testCases := []struct {
name string
raw []string
want []*action.PolicyInputFromFile
wantNil bool
wantErr bool
}{
{
name: "nil input returns nil",
raw: nil,
wantNil: true,
},
{
name: "empty input returns nil",
raw: []string{},
wantNil: true,
},
{
name: "malformed value propagates the parse error",
raw: []string{"missing-equals"},
wantErr: true,
},
{
name: "scheme-less missing path keeps the original file value",
raw: []string{"ignored_paths=/does/not/exist.csv:Path"},
want: []*action.PolicyInputFromFile{
{Input: "ignored_paths", Column: "Path", File: "/does/not/exist.csv"},
},
},
{
name: "multiple entries keep order and default the column",
raw: []string{
"ignored_paths=/no/exist1.csv",
"paths=/no/exist2.json:Glob",
},
want: []*action.PolicyInputFromFile{
{Input: "ignored_paths", Column: "ignored_paths", File: "/no/exist1.csv"},
{Input: "paths", Column: "Glob", File: "/no/exist2.json"},
},
},
{
name: "unresolvable env reference errors",
raw: []string{"ignored_paths=env://CHAINLOOP_TEST_DEFINITELY_UNSET_VAR"},
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := resolvePolicyInputFiles(tc.raw)
if tc.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tc.wantNil {
assert.Nil(t, got)
return
}
assert.Equal(t, tc.want, got)
})
}
}

// TestResolvePolicyInputFilesExistingFile checks that an on-disk file is
// resolved to its own path (no temporary copy).
func TestResolvePolicyInputFilesExistingFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "exception.csv")
require.NoError(t, os.WriteFile(path, []byte("Path\nc:\\a.dll\n"), 0600))

got, err := resolvePolicyInputFiles([]string{"ignored_paths=" + path + ":Path"})
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, &action.PolicyInputFromFile{Input: "ignored_paths", Column: "Path", File: path}, got[0])
}

// TestResolvePolicyInputFilesResolvesEnv checks that a non-file reference (here
// env://) is downloaded to a local temporary path that differs from the
// original reference.
func TestResolvePolicyInputFilesResolvesEnv(t *testing.T) {
t.Setenv("CHAINLOOP_TEST_POLICY_INPUT", `["c:\\a.dll"]`)

got, err := resolvePolicyInputFiles([]string{"ignored_paths=env://CHAINLOOP_TEST_POLICY_INPUT"})
require.NoError(t, err)
require.Len(t, got, 1)

assert.Equal(t, "ignored_paths", got[0].Input)
assert.Equal(t, "ignored_paths", got[0].Column)
// The env reference is materialized to a real local file.
assert.NotEqual(t, "env://CHAINLOOP_TEST_POLICY_INPUT", got[0].File)
content, err := os.ReadFile(got[0].File)
require.NoError(t, err)
assert.Equal(t, `["c:\\a.dll"]`, string(content))
}
26 changes: 16 additions & 10 deletions app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,21 +244,27 @@ chainloop attestation add --value <material-value>

Add a material by also providing a URL pointing to the material. It will be downloaded to a temporary folder first
chainloop attestation add --value https://example.com/sbom.json

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
```

Options

```
--annotation strings additional annotation in the format of key=value
--attestation-id string Unique identifier of the in-progress attestation
-h, --help help for add
--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)
--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)
--value string value to be recorded
--annotation strings additional annotation in the format of key=value
--attestation-id string Unique identifier of the in-progress attestation
-h, --help help for add
--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 <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.
--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)
--value string value to be recorded
```

Options inherited from parent commands
Expand Down
Loading
Loading