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
9 changes: 7 additions & 2 deletions app/cli/cmd/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <policy>: 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{
Expand Down Expand Up @@ -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 <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.")
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.")

// Optional OCI registry credentials
cmd.Flags().StringVar(&registryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName))
Expand Down
7 changes: 6 additions & 1 deletion app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <policy>: 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
Expand All @@ -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 <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.
--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.
--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)
Expand Down
40 changes: 25 additions & 15 deletions app/cli/pkg/action/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions app/cli/pkg/action/attestation_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
})
}
56 changes: 46 additions & 10 deletions app/cli/pkg/action/policy_input_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
// (<policy>@sha256:<digest>). 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
// "<input>=<file>[:<column>]". 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.
// "[<policy>:]<input>=<file>[:<column>]". The optional "<policy>:" 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 <input>=<file>[:<column>]", raw)
return nil, fmt.Errorf("invalid --policy-input-from-file %q: expected [<policy>:]<input>=<file>[:<column>]", raw)
}

// Split off the optional "<policy>:" 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 "<policy>@sha256:<digest>" (no input) would be mis-split into
// policy "<policy>@sha256" and input "<digest>"; 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 <policy>@sha256:<digest>:<input>=<file>", 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)
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions app/cli/pkg/action/policy_input_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand Down
7 changes: 4 additions & 3 deletions pkg/attestation/crafter/crafter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading