diff --git a/app/cli/cmd/attestation_add.go b/app/cli/cmd/attestation_add.go
index f41c73acd..8da6df7c7 100644
--- a/app/cli/cmd/attestation_add.go
+++ b/app/cli/cmd/attestation_add.go
@@ -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
@@ -91,6 +92,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(
@@ -110,7 +118,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
}
@@ -146,6 +154,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 =[:] (e.g. ignored_paths=exception.csv:Path); 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(®istryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName))
@@ -167,6 +176,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 {
diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx
index fd5105607..896864fec 100755
--- a/app/cli/documentation/cli-reference.mdx
+++ b/app/cli/documentation/cli-reference.mdx
@@ -249,16 +249,17 @@ chainloop attestation add --value https://example.com/sbom.json
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 =[:] (e.g. ignored_paths=exception.csv:Path); 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
diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go
index 6acc138ef..379a474c2 100644
--- a/app/cli/pkg/action/attestation_add.go
+++ b/app/cli/pkg/action/attestation_add.go
@@ -19,10 +19,13 @@ import (
"context"
"errors"
"fmt"
+ "strings"
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
+ schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
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"
"google.golang.org/grpc"
)
@@ -77,13 +80,20 @@ func NewAttestationAdd(cfg *AttestationAddOpts) (*AttestationAdd, error) {
var ErrAttestationNotInitialized = errors.New("attestation not yet initialized")
-func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string) (*AttestationStatusMaterial, error) {
+func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string, policyInputFiles []*PolicyInputFromFile) (*AttestationStatusMaterial, error) {
// initialize the crafter. If attestation-id is provided we assume the attestation is performed using remote state
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
if err != nil {
return nil, fmt.Errorf("failed to load crafter: %w", err)
}
+ // Resolve runtime policy inputs from the provided files before adding the
+ // material, so a malformed file aborts the add early.
+ runtimeInputs, err := buildRuntimeInputs(policyInputFiles)
+ if err != nil {
+ return nil, err
+ }
+
if initialized, err := crafter.AlreadyInitialized(ctx, attestationID); err != nil {
return nil, fmt.Errorf("checking if attestation is already initialized: %w", err)
} else if !initialized {
@@ -120,10 +130,12 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
// 2. If materialName is not empty, check if the material is in the contract. If it is, add material from contract
// 2.1. If materialType is empty, try to guess the material kind with auto-detected kind and materialName
// 3. If materialType is not empty, add material contract free with materialType and materialName
+ addOpts := runtimeInputAddOpts(runtimeInputs)
+
var mt *api.Attestation_Material
switch {
case materialName == "" && materialType == "":
- mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, "", materialValue, casBackend, annotations)
+ mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, "", materialValue, casBackend, annotations, addOpts...)
if err != nil {
return nil, fmt.Errorf("adding material: %w", err)
}
@@ -132,26 +144,32 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
switch {
// If the material is in the contract, add it from the contract
case crafter.IsMaterialInContract(materialName):
- mt, err = crafter.AddMaterialFromContract(ctx, attestationID, materialName, materialValue, casBackend, annotations)
+ mt, err = crafter.AddMaterialFromContract(ctx, attestationID, materialName, materialValue, casBackend, annotations, addOpts...)
// 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
case materialType == "":
- mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, materialName, materialValue, casBackend, annotations)
+ mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, materialName, materialValue, casBackend, annotations, addOpts...)
if err != nil {
return nil, fmt.Errorf("adding material: %w", err)
}
action.Logger.Info().Str("kind", mt.MaterialType.String()).Msg("material kind detected")
// If the material is not in the contract and has a materialType, add material contract free with the provided materialType
default:
- mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations)
+ mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations, addOpts...)
}
default:
- mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations)
+ mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations, addOpts...)
}
if err != nil {
return nil, fmt.Errorf("adding material: %w", err)
}
+ // Record each source file as an EVIDENCE material, cross-linked to the
+ // evaluated material so the exemption set itself is attested.
+ if err := action.addPolicyInputEvidence(ctx, crafter, attestationID, mt.GetId(), policyInputFiles, casBackend); err != nil {
+ return nil, fmt.Errorf("recording policy input evidence: %w", err)
+ }
+
materialResult, err := attMaterialToAction(mt)
if err != nil {
return nil, fmt.Errorf("converting material to action: %w", err)
@@ -160,6 +178,88 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
return materialResult, nil
}
+// 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 {
+ 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) {
+ if len(policyInputFiles) == 0 {
+ return nil, nil
+ }
+
+ runtimeInputs := make(map[string]string, len(policyInputFiles))
+ 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
+ } else {
+ runtimeInputs[pif.Input] = joined
+ }
+ }
+
+ return runtimeInputs, nil
+}
+
+// addPolicyInputEvidence adds each policy input file as an EVIDENCE material,
+// linked back to the evaluated material via the chainloop.material.references
+// annotation. The evidence material name is derived as "-";
+// when the same input is fed by more than one file, a "-" suffix keeps the
+// names unique so no evidence record is silently overwritten.
+func (action *AttestationAdd) addPolicyInputEvidence(ctx context.Context, c *crafter.Crafter, attestationID, materialName string, policyInputFiles []*PolicyInputFromFile, casBackend *casclient.CASBackend) error {
+ names := policyInputEvidenceNames(materialName, policyInputFiles)
+ for i, pif := range policyInputFiles {
+ annotations := map[string]string{
+ materials.AnnotationMaterialReferences: materialName,
+ }
+
+ if _, err := c.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_EVIDENCE.String(), names[i], pif.File, casBackend, annotations); err != nil {
+ return fmt.Errorf("adding evidence material %q: %w", names[i], err)
+ }
+ }
+
+ return nil
+}
+
+// policyInputEvidenceNames returns the evidence material name for each policy
+// input file, in order. Names are "-"; when the same input is
+// fed by more than one file, a "-" suffix keeps them unique so no evidence
+// record is silently overwritten in the attestation.
+func policyInputEvidenceNames(materialName string, policyInputFiles []*PolicyInputFromFile) []string {
+ inputCount := make(map[string]int, len(policyInputFiles))
+ for _, pif := range policyInputFiles {
+ inputCount[pif.Input]++
+ }
+
+ names := make([]string, len(policyInputFiles))
+ seen := make(map[string]int, len(policyInputFiles))
+ for i, pif := range policyInputFiles {
+ name := fmt.Sprintf("%s-%s", materialName, pif.Input)
+ if inputCount[pif.Input] > 1 {
+ seen[pif.Input]++
+ name = fmt.Sprintf("%s-%d", name, seen[pif.Input])
+ }
+ names[i] = name
+ }
+
+ return names
+}
+
// GetPolicyEvaluations is a Wrapper around the getPolicyEvaluations
func (action *AttestationAdd) GetPolicyEvaluations(ctx context.Context, attestationID string) (map[string][]*PolicyEvaluation, error) {
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
diff --git a/app/cli/pkg/action/attestation_add_test.go b/app/cli/pkg/action/attestation_add_test.go
new file mode 100644
index 000000000..2e66e0101
--- /dev/null
+++ b/app/cli/pkg/action/attestation_add_test.go
@@ -0,0 +1,63 @@
+//
+// 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 action
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPolicyInputEvidenceNames(t *testing.T) {
+ testCases := []struct {
+ name string
+ materialName string
+ files []*PolicyInputFromFile
+ want []string
+ }{
+ {
+ name: "single input keeps the plain name",
+ materialName: "binaries",
+ files: []*PolicyInputFromFile{{Input: "ignored_paths"}},
+ want: []string{"binaries-ignored_paths"},
+ },
+ {
+ name: "distinct inputs are not suffixed",
+ materialName: "binaries",
+ files: []*PolicyInputFromFile{{Input: "ignored_paths"}, {Input: "paths"}},
+ want: []string{"binaries-ignored_paths", "binaries-paths"},
+ },
+ {
+ name: "same input fed by multiple files is disambiguated",
+ materialName: "binaries",
+ files: []*PolicyInputFromFile{{Input: "ignored_paths"}, {Input: "ignored_paths"}},
+ want: []string{"binaries-ignored_paths-1", "binaries-ignored_paths-2"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := policyInputEvidenceNames(tc.materialName, tc.files)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestBuildRuntimeInputsNil(t *testing.T) {
+ got, err := buildRuntimeInputs(nil)
+ assert.NoError(t, err)
+ assert.Nil(t, got)
+}
diff --git a/app/cli/pkg/action/policy_input_file.go b/app/cli/pkg/action/policy_input_file.go
new file mode 100644
index 000000000..e2fe22964
--- /dev/null
+++ b/app/cli/pkg/action/policy_input_file.go
@@ -0,0 +1,197 @@
+//
+// 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 action
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/chainloop-dev/chainloop/pkg/tabular"
+)
+
+// 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 {
+ // Input is the destination policy input name (e.g. "ignored_paths").
+ Input string
+ // Column is the file column/field to extract. Defaults to Input.
+ Column string
+ // File is the source CSV or JSON file path.
+ File string
+}
+
+// ParsePolicyInputFromFile parses a single flag value of the form
+// "=[:]". 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, "=")
+ if !found {
+ return nil, fmt.Errorf("invalid --policy-input-from-file %q: expected =[:]", raw)
+ }
+
+ input = strings.TrimSpace(input)
+ rhs = strings.TrimSpace(rhs)
+ if input == "" {
+ return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing input name", raw)
+ }
+ if rhs == "" {
+ return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing file path", raw)
+ }
+
+ // Default the column to the input name; override it only when a ":"
+ // suffix is present and unambiguously a column (no path separator).
+ file := rhs
+ column := input
+ if i := strings.LastIndex(rhs, ":"); i >= 0 {
+ if candidate := strings.TrimSpace(rhs[i+1:]); candidate != "" && !strings.ContainsAny(candidate, `/\`) {
+ file = strings.TrimSpace(rhs[:i])
+ column = candidate
+ }
+ }
+
+ if file == "" {
+ return nil, fmt.Errorf("invalid --policy-input-from-file %q: missing file path", raw)
+ }
+
+ return &PolicyInputFromFile{Input: input, Column: column, File: file}, nil
+}
+
+// ExtractColumnValues reads the given CSV or JSON file and returns the values of
+// the named column/field. Format is detected by extension, with a content-sniff
+// fallback. Empty and whitespace-only values are dropped. CSV parsing reuses the
+// sigcheck parser (BOM decoding, comma/tab auto-detection, case-insensitive
+// header match).
+func ExtractColumnValues(path, column string) ([]string, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading policy input file: %w", err)
+ }
+
+ // Strip a leading UTF-8 BOM (common on Windows-authored files) so both
+ // format detection and JSON parsing see clean bytes. The CSV path strips
+ // it again inside sigcheck.Parse, which is harmless.
+ content = bytes.TrimPrefix(content, []byte("\xef\xbb\xbf"))
+
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".json":
+ return extractJSONColumn(content, column)
+ case ".csv", ".tsv", ".txt":
+ return extractCSVColumn(content, column)
+ default:
+ // Content sniff: a leading "[" or "{" means JSON, otherwise treat as CSV.
+ if t := bytes.TrimSpace(content); len(t) > 0 && (t[0] == '[' || t[0] == '{') {
+ return extractJSONColumn(content, column)
+ }
+ return extractCSVColumn(content, column)
+ }
+}
+
+func extractCSVColumn(content []byte, column string) ([]string, error) {
+ table, err := tabular.Parse(content)
+ if err != nil {
+ return nil, fmt.Errorf("parsing CSV policy input file: %w", err)
+ }
+
+ values, ok := table.Column(column)
+ if !ok {
+ return nil, fmt.Errorf("column %q not found in CSV header %v", column, table.Header)
+ }
+
+ return values, nil
+}
+
+// extractJSONColumn extracts column values from one of three accepted shapes:
+// a bare array of strings, an array of string-valued objects (the column field
+// of each), or an object mapping the column to an array of strings. The column
+// is matched only against top-level keys; nested paths are not interpreted.
+func extractJSONColumn(content []byte, column string) ([]string, error) {
+ trimmed := bytes.TrimSpace(content)
+ if len(trimmed) == 0 {
+ return nil, errors.New("empty JSON policy input file")
+ }
+
+ switch trimmed[0] {
+ case '[':
+ // Bare array of strings.
+ var strs []string
+ if err := json.Unmarshal(trimmed, &strs); err == nil {
+ return filterNonEmpty(strs), nil
+ }
+
+ // Array of string-valued objects: pull the column field from each.
+ var objs []map[string]string
+ if err := json.Unmarshal(trimmed, &objs); err != nil {
+ return nil, fmt.Errorf("parsing JSON array (expected an array of strings or of string-valued objects): %w", err)
+ }
+ values := make([]string, 0, len(objs))
+ for _, obj := range objs {
+ if v, ok := matchKey(obj, column); ok {
+ values = append(values, v)
+ }
+ }
+ return filterNonEmpty(values), nil
+ case '{':
+ // Object mapping the column to an array of strings. The values are
+ // decoded into a typed []string; sibling keys are left as raw messages
+ // so fields of other types don't break the parse.
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(trimmed, &obj); err != nil {
+ return nil, fmt.Errorf("parsing JSON object: %w", err)
+ }
+ raw, ok := matchKey(obj, column)
+ if !ok {
+ return nil, fmt.Errorf("key %q not found in JSON object", column)
+ }
+ var strs []string
+ if err := json.Unmarshal(raw, &strs); err != nil {
+ return nil, fmt.Errorf("value of %q is not an array of strings: %w", column, err)
+ }
+ return filterNonEmpty(strs), nil
+ default:
+ return nil, errors.New("JSON policy input file must be an array or object")
+ }
+}
+
+// matchKey returns the value whose key matches column case-insensitively
+// (trimming surrounding whitespace).
+func matchKey[T any](m map[string]T, column string) (T, bool) {
+ for k, v := range m {
+ if strings.EqualFold(strings.TrimSpace(k), strings.TrimSpace(column)) {
+ return v, true
+ }
+ }
+ var zero T
+ return zero, false
+}
+
+func filterNonEmpty(values []string) []string {
+ out := make([]string, 0, len(values))
+ for _, v := range values {
+ if v = strings.TrimSpace(v); v != "" {
+ out = append(out, v)
+ }
+ }
+ return out
+}
diff --git a/app/cli/pkg/action/policy_input_file_test.go b/app/cli/pkg/action/policy_input_file_test.go
new file mode 100644
index 000000000..1b12105da
--- /dev/null
+++ b/app/cli/pkg/action/policy_input_file_test.go
@@ -0,0 +1,195 @@
+//
+// 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 action
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePolicyInputFromFile(t *testing.T) {
+ testCases := []struct {
+ name string
+ raw string
+ want *PolicyInputFromFile
+ wantErr bool
+ }{
+ {
+ name: "input, file and column",
+ raw: "ignored_paths=exception.csv:Path",
+ want: &PolicyInputFromFile{Input: "ignored_paths", Column: "Path", File: "exception.csv"},
+ },
+ {
+ name: "column defaults to input name",
+ raw: "ignored_paths=exception.csv",
+ want: &PolicyInputFromFile{Input: "ignored_paths", Column: "ignored_paths", File: "exception.csv"},
+ },
+ {
+ name: "windows drive letter without column is not split",
+ raw: `ignored_paths=C:\data\exception.csv`,
+ want: &PolicyInputFromFile{Input: "ignored_paths", Column: "ignored_paths", File: `C:\data\exception.csv`},
+ },
+ {
+ name: "windows drive letter with column",
+ raw: `ignored_paths=C:\data\exception.csv:Path`,
+ want: &PolicyInputFromFile{Input: "ignored_paths", Column: "Path", File: `C:\data\exception.csv`},
+ },
+ {
+ name: "url file without column is not split",
+ raw: "ignored_paths=https://example.com/exception.csv",
+ want: &PolicyInputFromFile{Input: "ignored_paths", Column: "ignored_paths", File: "https://example.com/exception.csv"},
+ },
+ {
+ name: "column with a space",
+ raw: "versions=exception.csv:Product Version",
+ want: &PolicyInputFromFile{Input: "versions", Column: "Product Version", File: "exception.csv"},
+ },
+ {
+ name: "surrounding whitespace trimmed",
+ raw: " ignored_paths = exception.csv : Path ",
+ want: &PolicyInputFromFile{Input: "ignored_paths", Column: "Path", File: "exception.csv"},
+ },
+ {
+ name: "missing equals",
+ raw: "ignored_paths:Path",
+ wantErr: true,
+ },
+ {
+ name: "missing input name",
+ raw: "=exception.csv",
+ wantErr: true,
+ },
+ {
+ name: "missing file",
+ raw: "ignored_paths:Path=",
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ParsePolicyInputFromFile(tc.raw)
+ if tc.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestExtractColumnValues(t *testing.T) {
+ testCases := []struct {
+ name string
+ filename string
+ content string
+ column string
+ want []string
+ wantErr bool
+ }{
+ {
+ name: "CSV pulls named column case-insensitively",
+ filename: "exception.csv",
+ content: "Path,Verified,Publisher\nc:\\a.dll,Signed,Acme\nc:\\b.dll,Unsigned,Acme\n",
+ column: "path",
+ want: []string{"c:\\a.dll", "c:\\b.dll"},
+ },
+ {
+ name: "CSV drops empty cells",
+ filename: "exception.csv",
+ content: "Path,Other\nc:\\a.dll,x\n,y\nc:\\b.dll,z\n",
+ column: "Path",
+ want: []string{"c:\\a.dll", "c:\\b.dll"},
+ },
+ {
+ name: "CSV missing column errors",
+ filename: "exception.csv",
+ content: "Path\nc:\\a.dll\n",
+ column: "Nope",
+ wantErr: true,
+ },
+ {
+ name: "JSON array of strings",
+ filename: "exception.json",
+ content: `["c:\\a.dll", "c:\\b.dll", ""]`,
+ column: "ignored_paths",
+ want: []string{"c:\\a.dll", "c:\\b.dll"},
+ },
+ {
+ name: "JSON with UTF-8 BOM prefix",
+ filename: "exception.json",
+ content: "\xef\xbb\xbf[\"c:\\\\a.dll\"]",
+ column: "ignored_paths",
+ want: []string{"c:\\a.dll"},
+ },
+ {
+ name: "JSON array of objects",
+ filename: "exception.json",
+ content: `[{"Path":"c:\\a.dll","Publisher":"Acme"},{"Path":"c:\\b.dll"}]`,
+ column: "Path",
+ want: []string{"c:\\a.dll", "c:\\b.dll"},
+ },
+ {
+ name: "JSON object mapping column to array",
+ filename: "exception.json",
+ content: `{"ignored_paths":["c:\\a.dll","c:\\b.dll"]}`,
+ column: "ignored_paths",
+ want: []string{"c:\\a.dll", "c:\\b.dll"},
+ },
+ {
+ name: "JSON object missing key errors",
+ filename: "exception.json",
+ content: `{"other":["x"]}`,
+ column: "ignored_paths",
+ wantErr: true,
+ },
+ {
+ name: "content sniff detects JSON without extension",
+ filename: "exception.dat",
+ content: `["c:\\a.dll"]`,
+ column: "ignored_paths",
+ want: []string{"c:\\a.dll"},
+ },
+ {
+ name: "content sniff falls back to CSV without extension",
+ filename: "exception.dat",
+ content: "Path\nc:\\a.dll\n",
+ column: "Path",
+ want: []string{"c:\\a.dll"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, tc.filename)
+ require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600))
+
+ got, err := ExtractColumnValues(path, tc.column)
+ if tc.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
diff --git a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts
index ac115b714..dd0183705 100644
--- a/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts
+++ b/app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts
@@ -301,6 +301,13 @@ export interface PolicyEvaluation {
rawResults: PolicyEvaluation_RawResult[];
/** Whether the policy evaluation result should block the attestation (inherited from the policy attachment) */
gate: boolean;
+ /**
+ * Names of policy inputs whose values were supplied or overridden at runtime
+ * (e.g. via --policy-input-from-file) rather than coming solely from the
+ * contract. The effective merged values live in `with`; this only records
+ * which input names were touched at runtime. Empty means no runtime override.
+ */
+ runtimeInputOverrides: string[];
}
export interface PolicyEvaluation_AnnotationsEntry {
@@ -2449,6 +2456,7 @@ function createBasePolicyEvaluation(): PolicyEvaluation {
requirements: [],
rawResults: [],
gate: false,
+ runtimeInputOverrides: [],
};
}
@@ -2508,6 +2516,9 @@ export const PolicyEvaluation = {
if (message.gate === true) {
writer.uint32(152).bool(message.gate);
}
+ for (const v of message.runtimeInputOverrides) {
+ writer.uint32(162).string(v!);
+ }
return writer;
},
@@ -2650,6 +2661,13 @@ export const PolicyEvaluation = {
message.gate = reader.bool();
continue;
+ case 20:
+ if (tag !== 162) {
+ break;
+ }
+
+ message.runtimeInputOverrides.push(reader.string());
+ continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -2699,6 +2717,9 @@ export const PolicyEvaluation = {
? object.rawResults.map((e: any) => PolicyEvaluation_RawResult.fromJSON(e))
: [],
gate: isSet(object.gate) ? Boolean(object.gate) : false,
+ runtimeInputOverrides: Array.isArray(object?.runtimeInputOverrides)
+ ? object.runtimeInputOverrides.map((e: any) => String(e))
+ : [],
};
},
@@ -2756,6 +2777,11 @@ export const PolicyEvaluation = {
obj.rawResults = [];
}
message.gate !== undefined && (obj.gate = message.gate);
+ if (message.runtimeInputOverrides) {
+ obj.runtimeInputOverrides = message.runtimeInputOverrides.map((e) => e);
+ } else {
+ obj.runtimeInputOverrides = [];
+ }
return obj;
},
@@ -2800,6 +2826,7 @@ export const PolicyEvaluation = {
message.requirements = object.requirements?.map((e) => e) || [];
message.rawResults = object.rawResults?.map((e) => PolicyEvaluation_RawResult.fromPartial(e)) || [];
message.gate = object.gate ?? false;
+ message.runtimeInputOverrides = object.runtimeInputOverrides?.map((e) => e) || [];
return message;
},
};
diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json
index 64d4294cb..e023142db 100644
--- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json
+++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json
@@ -28,6 +28,13 @@
"^(reference_name)$": {
"type": "string"
},
+ "^(runtime_input_overrides)$": {
+ "description": "Names of policy inputs whose values were supplied or overridden at runtime\n (e.g. via --policy-input-from-file) rather than coming solely from the\n contract. The effective merged values live in `with`; this only records\n which input names were touched at runtime. Empty means no runtime override.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"^(skip_reasons)$": {
"description": "Evaluation messages, intended to communicate evaluation errors (invalid input)",
"items": {
@@ -92,6 +99,13 @@
},
"type": "array"
},
+ "runtimeInputOverrides": {
+ "description": "Names of policy inputs whose values were supplied or overridden at runtime\n (e.g. via --policy-input-from-file) rather than coming solely from the\n contract. The effective merged values live in `with`; this only records\n which input names were touched at runtime. Empty means no runtime override.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"skipReasons": {
"description": "Evaluation messages, intended to communicate evaluation errors (invalid input)",
"items": {
diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json
index a19e8086d..47e3c449e 100644
--- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json
+++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json
@@ -28,6 +28,13 @@
"^(referenceName)$": {
"type": "string"
},
+ "^(runtimeInputOverrides)$": {
+ "description": "Names of policy inputs whose values were supplied or overridden at runtime\n (e.g. via --policy-input-from-file) rather than coming solely from the\n contract. The effective merged values live in `with`; this only records\n which input names were touched at runtime. Empty means no runtime override.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"^(skipReasons)$": {
"description": "Evaluation messages, intended to communicate evaluation errors (invalid input)",
"items": {
@@ -92,6 +99,13 @@
},
"type": "array"
},
+ "runtime_input_overrides": {
+ "description": "Names of policy inputs whose values were supplied or overridden at runtime\n (e.g. via --policy-input-from-file) rather than coming solely from the\n contract. The effective merged values live in `with`; this only records\n which input names were touched at runtime. Empty means no runtime override.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"skip_reasons": {
"description": "Evaluation messages, intended to communicate evaluation errors (invalid input)",
"items": {
diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go
index 3512fb801..5dd45f4f2 100644
--- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go
+++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go
@@ -31,7 +31,7 @@ import (
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/jacoco"
materialsjunit "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/junit"
materialsradamsa "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/radamsa"
- "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/sigcheck"
+ "github.com/chainloop-dev/chainloop/pkg/tabular"
intoto "github.com/in-toto/attestation/go/v1"
"google.golang.org/protobuf/types/known/structpb"
)
@@ -205,7 +205,7 @@ func (m *Attestation_Material) ingestMaterialToJSON(rawMaterial []byte, value st
}
return json.Marshal(&report)
case v1.CraftingSchema_Material_SYSINTERNALS_SIGCHECK:
- report, err := sigcheck.Parse(rawMaterial)
+ report, err := tabular.Parse(rawMaterial)
if err != nil {
return nil, fmt.Errorf("failed to ingest sigcheck report: %w", err)
}
diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go
index c64e1dac5..fada6ff59 100644
--- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go
+++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.pb.go
@@ -474,9 +474,14 @@ type PolicyEvaluation struct {
// Raw inputs and outputs from the policy engine, preserved for debugging.
RawResults []*PolicyEvaluation_RawResult `protobuf:"bytes,18,rep,name=raw_results,json=rawResults,proto3" json:"raw_results,omitempty"`
// Whether the policy evaluation result should block the attestation (inherited from the policy attachment)
- Gate bool `protobuf:"varint,19,opt,name=gate,proto3" json:"gate,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ Gate bool `protobuf:"varint,19,opt,name=gate,proto3" json:"gate,omitempty"`
+ // Names of policy inputs whose values were supplied or overridden at runtime
+ // (e.g. via --policy-input-from-file) rather than coming solely from the
+ // contract. The effective merged values live in `with`; this only records
+ // which input names were touched at runtime. Empty means no runtime override.
+ RuntimeInputOverrides []string `protobuf:"bytes,20,rep,name=runtime_input_overrides,json=runtimeInputOverrides,proto3" json:"runtime_input_overrides,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *PolicyEvaluation) Reset() {
@@ -638,6 +643,13 @@ func (x *PolicyEvaluation) GetGate() bool {
return false
}
+func (x *PolicyEvaluation) GetRuntimeInputOverrides() []string {
+ if x != nil {
+ return x.RuntimeInputOverrides
+ }
+ return nil
+}
+
// Bundle of all policy evaluations for an attestation, stored as a CAS object.
type PolicyEvaluationBundle struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2903,7 +2915,7 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" +
"\venvironment\x18\x02 \x01(\tR\venvironment\x12$\n" +
"\rauthenticated\x18\x03 \x01(\bR\rauthenticated\x12I\n" +
"\x04type\x18\x04 \x01(\x0e25.workflowcontract.v1.CraftingSchema.Runner.RunnerTypeR\x04type\x12\x10\n" +
- "\x03url\x18\x05 \x01(\tR\x03url\"\xb4\x0e\n" +
+ "\x03url\x18\x05 \x01(\tR\x03url\"\xec\x0e\n" +
"\x10PolicyEvaluation\x12\x97\x01\n" +
"\x04name\x18\x01 \x01(\tB\x82\x01\xbaH\x7f\xba\x01|\n" +
"\rname.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a/this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x04name\x12#\n" +
@@ -2927,7 +2939,8 @@ const file_attestation_v1_crafting_state_proto_rawDesc = "" +
"\frequirements\x18\x11 \x03(\tR\frequirements\x12K\n" +
"\vraw_results\x18\x12 \x03(\v2*.attestation.v1.PolicyEvaluation.RawResultR\n" +
"rawResults\x12\x12\n" +
- "\x04gate\x18\x13 \x01(\bR\x04gate\x1a>\n" +
+ "\x04gate\x18\x13 \x01(\bR\x04gate\x126\n" +
+ "\x17runtime_input_overrides\x18\x14 \x03(\tR\x15runtimeInputOverrides\x1a>\n" +
"\x10AnnotationsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a7\n" +
diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto
index 702588cc9..58a37d38d 100644
--- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto
+++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.proto
@@ -276,6 +276,12 @@ message PolicyEvaluation {
// Whether the policy evaluation result should block the attestation (inherited from the policy attachment)
bool gate = 19;
+ // Names of policy inputs whose values were supplied or overridden at runtime
+ // (e.g. via --policy-input-from-file) rather than coming solely from the
+ // contract. The effective merged values live in `with`; this only records
+ // which input names were touched at runtime. Empty means no runtime override.
+ repeated string runtime_input_overrides = 20;
+
message Violation {
string subject = 1 [(buf.validate.field).required = true];
string message = 2 [(buf.validate.field).required = true];
diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go
index fa1db17a8..5a2db615b 100644
--- a/pkg/attestation/crafter/crafter.go
+++ b/pkg/attestation/crafter/crafter.go
@@ -561,10 +561,29 @@ func (c *Crafter) resolveRunnerInfo() {
}
}
+// AddOpt customizes a material add operation.
+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
+}
+
+// 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 {
+ return func(o *addOpts) {
+ o.runtimeInputs = inputs
+ }
+}
+
// AddMaterialContractFree adds a material to the crafting state without checking the contract schema.
// This is useful for adding materials that are not defined in the schema.
// The name of the material is automatically calculated to conform the API contract if not provided.
-func (c *Crafter) AddMaterialContractFree(ctx context.Context, attestationID, kind, name, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string) (*api.Attestation_Material, error) {
+func (c *Crafter) AddMaterialContractFree(ctx context.Context, attestationID, kind, name, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string, opts ...AddOpt) (*api.Attestation_Material, error) {
if err := c.requireStateLoaded(); err != nil {
return nil, fmt.Errorf("adding materials outisde the contract: %w", err)
}
@@ -585,12 +604,12 @@ func (c *Crafter) AddMaterialContractFree(ctx context.Context, attestationID, ki
}
// 3 - Craft resulting material
- return c.addMaterial(ctx, &m, attestationID, value, casBackend, runtimeAnnotations)
+ return c.addMaterial(ctx, &m, attestationID, value, casBackend, runtimeAnnotations, opts...)
}
// AddMaterialFromContract adds a material to the crafting state checking the incoming materials is
// in the schema and has not been set yet
-func (c *Crafter) AddMaterialFromContract(ctx context.Context, attestationID, key, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string) (*api.Attestation_Material, error) {
+func (c *Crafter) AddMaterialFromContract(ctx context.Context, attestationID, key, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string, opts ...AddOpt) (*api.Attestation_Material, error) {
if err := c.requireStateLoaded(); err != nil {
return nil, fmt.Errorf("adding materials outisde from contract: %w", err)
}
@@ -613,7 +632,7 @@ func (c *Crafter) AddMaterialFromContract(ctx context.Context, attestationID, ke
}
// 3 - Craft resulting material
- return c.addMaterial(ctx, m, attestationID, value, casBackend, runtimeAnnotations)
+ return c.addMaterial(ctx, m, attestationID, value, casBackend, runtimeAnnotations, opts...)
}
// IsMaterialInContract checks if the material is in the contract schema
@@ -633,10 +652,10 @@ func (c *Crafter) IsMaterialInContract(key string) bool {
// AddMaterialContactFreeWithAutoDetectedKind adds a material to the crafting state checking the incoming material matches any of the
// supported types in validation order. If the material is not found it will return an error.
-func (c *Crafter) AddMaterialContactFreeWithAutoDetectedKind(ctx context.Context, attestationID, name, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string) (*api.Attestation_Material, error) {
+func (c *Crafter) AddMaterialContactFreeWithAutoDetectedKind(ctx context.Context, attestationID, name, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string, opts ...AddOpt) (*api.Attestation_Material, error) {
var err error
for _, kind := range schemaapi.CraftingMaterialInValidationOrder {
- m, err := c.AddMaterialContractFree(ctx, attestationID, kind.String(), name, value, casBackend, runtimeAnnotations)
+ m, err := c.AddMaterialContractFree(ctx, attestationID, kind.String(), name, value, casBackend, runtimeAnnotations, opts...)
if err == nil {
// Successfully added material, return the kind
return m, nil
@@ -662,7 +681,12 @@ func (c *Crafter) AddMaterialContactFreeWithAutoDetectedKind(ctx context.Context
}
// addMaterials adds the incoming material m to the crafting state
-func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_Material, attestationID, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string) (*api.Attestation_Material, error) {
+func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_Material, attestationID, value string, casBackend *casclient.CASBackend, runtimeAnnotations map[string]string, opts ...AddOpt) (*api.Attestation_Material, error) {
+ addOptions := &addOpts{}
+ for _, opt := range opts {
+ opt(addOptions)
+ }
+
// 3- Craft resulting material
mt, err := materials.Craft(context.Background(), m, value, casBackend, c.ociRegistryAuth, c.Logger, &materials.CraftingOpts{
NoStrictValidation: c.noStrictValidation,
@@ -731,7 +755,9 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
// log group policy violations
policies.LogPolicyEvaluations(policyGroupResults, c.Logger)
- // Validate policies
+ // Validate policies. Runtime-supplied inputs (e.g. from
+ // --policy-input-from-file) are merged additively onto contract arguments;
+ // policy groups deliberately do not receive them.
pv := policies.NewPolicyVerifier(
c.CraftingState.GetPolicies(),
c.attClient,
@@ -739,6 +765,7 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
policies.WithProjectContext(projectName, projectVersion),
+ policies.WithRuntimeInputs(addOptions.runtimeInputs),
)
policyResults, err := pv.VerifyMaterial(ctx, mt, value)
if err != nil {
diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go
index b320be610..7cc0dbbcc 100644
--- a/pkg/attestation/crafter/materials/materials.go
+++ b/pkg/attestation/crafter/materials/materials.go
@@ -41,6 +41,12 @@ const (
AnnotationToolVersionKey = "chainloop.material.tool.version"
AnnotationToolsKey = "chainloop.material.tools"
AnnotationMaterialSize = "chainloop.material.size"
+ // AnnotationMaterialReferences links a material to one or more other
+ // materials in the same attestation by their name (comma-separated).
+ // Modeled on the OCI referrers API: the edge is stored on the referrer
+ // (e.g. the EVIDENCE file) pointing at its subject(s); the reverse
+ // direction is resolved by lookup. Generic across material kinds.
+ AnnotationMaterialReferences = "chainloop.material.references"
)
// IsLegacyAnnotation returns true if the annotation key is a legacy annotation
diff --git a/pkg/attestation/crafter/materials/sigcheck.go b/pkg/attestation/crafter/materials/sigcheck.go
index be37dce5a..739256e17 100644
--- a/pkg/attestation/crafter/materials/sigcheck.go
+++ b/pkg/attestation/crafter/materials/sigcheck.go
@@ -22,8 +22,8 @@ import (
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
- "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/sigcheck"
"github.com/chainloop-dev/chainloop/pkg/casclient"
+ "github.com/chainloop-dev/chainloop/pkg/tabular"
"github.com/rs/zerolog"
)
@@ -46,7 +46,7 @@ func (i *SigcheckCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
return nil, fmt.Errorf("can't open the file: %w", err)
}
- report, err := sigcheck.Parse(data)
+ report, err := tabular.Parse(data)
if err != nil {
return nil, fmt.Errorf("invalid sigcheck report: %w", ErrInvalidMaterialType)
}
diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go
index 602931360..bb58ac983 100644
--- a/pkg/policies/policies.go
+++ b/pkg/policies/policies.go
@@ -20,6 +20,7 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "maps"
"net/url"
"path/filepath"
"regexp"
@@ -99,6 +100,7 @@ type PolicyVerifier struct {
groupCache cache.Cache[*groupWithReference]
projectName string
projectVersionName string
+ runtimeInputs map[string]string
}
var _ Verifier = (*PolicyVerifier)(nil)
@@ -115,6 +117,7 @@ type PolicyVerifierOptions struct {
GroupCache cache.Cache[*groupWithReference]
ProjectName string
ProjectVersionName string
+ RuntimeInputs map[string]string
}
type PolicyVerifierOption func(*PolicyVerifierOptions)
@@ -185,6 +188,15 @@ func WithProjectContext(name, version string) PolicyVerifierOption {
}
}
+// WithRuntimeInputs sets policy input values supplied at runtime (e.g. sourced
+// from a file via --policy-input-from-file). They are merged additively onto
+// the contract-declared arguments during standalone material policy evaluation.
+func WithRuntimeInputs(inputs map[string]string) PolicyVerifierOption {
+ return func(o *PolicyVerifierOptions) {
+ o.RuntimeInputs = inputs
+ }
+}
+
const defaultPolicyCacheTTL = 5 * time.Minute
func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClient, logger *zerolog.Logger, opts ...PolicyVerifierOption) *PolicyVerifier {
@@ -221,6 +233,7 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien
groupCache: options.GroupCache,
projectName: options.ProjectName,
projectVersionName: options.ProjectVersionName,
+ runtimeInputs: options.RuntimeInputs,
}
}
@@ -250,7 +263,7 @@ func (pv *PolicyVerifier) VerifyMaterial(ctx context.Context, material *v12.Atte
for i, attachment := range attachments {
g.Go(func() error {
ev, err := pv.evaluatePolicyAttachment(gCtx, attachment, subject,
- &evalOpts{kind: material.MaterialType, name: material.GetId()},
+ &evalOpts{kind: material.MaterialType, name: material.GetId(), runtimeInputs: pv.runtimeInputs},
)
if err != nil {
return NewPolicyError(err)
@@ -279,6 +292,34 @@ type evalOpts struct {
kind v1.CraftingSchema_Material_MaterialType
// Argument bindings for policy evaluations
bindings map[string]string
+ // runtimeInputs holds policy input values supplied at runtime (e.g. from a
+ // file via --policy-input-from-file). They are merged additively onto the
+ // contract-declared arguments, kept separate from bindings so policy-group
+ // argument resolution is never mis-flagged as a runtime override.
+ runtimeInputs map[string]string
+}
+
+// mergeRuntimeInputs returns the contract arguments with the runtime inputs
+// merged in additively: when both define the same key, the runtime value is
+// appended after the contract value (newline-separated) so file-sourced
+// exemptions add to, rather than replace, contract-declared ones. The input
+// maps are not mutated.
+func mergeRuntimeInputs(with, runtimeInputs map[string]string) map[string]string {
+ if len(runtimeInputs) == 0 {
+ return with
+ }
+
+ merged := make(map[string]string, len(with)+len(runtimeInputs))
+ maps.Copy(merged, with)
+ for k, v := range runtimeInputs {
+ if existing := merged[k]; existing != "" {
+ merged[k] = existing + "\n" + v
+ } else {
+ merged[k] = v
+ }
+ }
+
+ return merged
}
// shouldEvaluateAtPhase checks if a policy should be evaluated at the given phase.
@@ -355,11 +396,26 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme
pv.logger.Debug().Msgf("evaluating policy %s against attestation", policy.Metadata.Name)
}
- args, err := ComputeArguments(policy.GetMetadata().GetName(), policy.GetSpec().GetInputs(), attachment.GetWith(), opts.bindings, pv.logger)
+ // Merge runtime-supplied inputs additively onto the contract arguments
+ // before computing the effective values.
+ with := mergeRuntimeInputs(attachment.GetWith(), opts.runtimeInputs)
+
+ args, err := ComputeArguments(policy.GetMetadata().GetName(), policy.GetSpec().GetInputs(), with, opts.bindings, pv.logger)
if err != nil {
return nil, NewPolicyError(err)
}
+ // Record which runtime inputs actually applied to this policy (i.e. made it
+ // into the computed args because the policy declares them). The values
+ // themselves live in `with`; this only flags the overridden input names.
+ var runtimeInputOverrides []string
+ for k := range opts.runtimeInputs {
+ if _, ok := args[k]; ok {
+ runtimeInputOverrides = append(runtimeInputOverrides, k)
+ }
+ }
+ slices.Sort(runtimeInputOverrides)
+
sources := make([]string, 0)
evalResults := make([]*engine.EvaluationResult, 0)
rawResults := make([]*engine.RawData, 0)
@@ -423,13 +479,14 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme
MaterialName: opts.name,
Sources: evaluationSources,
// merge all violations
- Violations: apiViolations,
- Annotations: policy.GetMetadata().GetAnnotations(),
- Description: policy.GetMetadata().GetDescription(),
- With: args,
- Type: opts.kind,
- ReferenceName: ref.GetURI(),
- ReferenceDigest: ref.GetDigest(),
+ Violations: apiViolations,
+ Annotations: policy.GetMetadata().GetAnnotations(),
+ Description: policy.GetMetadata().GetDescription(),
+ With: args,
+ RuntimeInputOverrides: runtimeInputOverrides,
+ Type: opts.kind,
+ ReferenceName: ref.GetURI(),
+ ReferenceDigest: ref.GetDigest(),
PolicyReference: &v12.PolicyEvaluation_Reference{
Name: policy.GetMetadata().GetName(),
Digest: ref.GetDigest(),
diff --git a/pkg/policies/runtime_inputs_test.go b/pkg/policies/runtime_inputs_test.go
new file mode 100644
index 000000000..fb39193a8
--- /dev/null
+++ b/pkg/policies/runtime_inputs_test.go
@@ -0,0 +1,80 @@
+//
+// 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 policies
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMergeRuntimeInputs(t *testing.T) {
+ testCases := []struct {
+ name string
+ with map[string]string
+ runtimeInputs map[string]string
+ want map[string]string
+ }{
+ {
+ name: "no runtime inputs returns contract args unchanged",
+ with: map[string]string{"ignored_paths": "a,b"},
+ runtimeInputs: nil,
+ want: map[string]string{"ignored_paths": "a,b"},
+ },
+ {
+ name: "runtime input on empty contract key",
+ with: map[string]string{},
+ runtimeInputs: map[string]string{"ignored_paths": "c\nd"},
+ want: map[string]string{"ignored_paths": "c\nd"},
+ },
+ {
+ name: "runtime input merges additively with contract value",
+ with: map[string]string{"ignored_paths": "a,b"},
+ runtimeInputs: map[string]string{"ignored_paths": "c\nd"},
+ want: map[string]string{"ignored_paths": "a,b\nc\nd"},
+ },
+ {
+ name: "runtime input on a different key is added alongside",
+ with: map[string]string{"paths": "**"},
+ runtimeInputs: map[string]string{"ignored_paths": "c"},
+ want: map[string]string{"paths": "**", "ignored_paths": "c"},
+ },
+ {
+ name: "empty contract value is replaced, not prefixed with newline",
+ with: map[string]string{"ignored_paths": ""},
+ runtimeInputs: map[string]string{"ignored_paths": "c"},
+ want: map[string]string{"ignored_paths": "c"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := mergeRuntimeInputs(tc.with, tc.runtimeInputs)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+// TestMergeRuntimeInputsDoesNotMutate ensures the input maps are left untouched.
+func TestMergeRuntimeInputsDoesNotMutate(t *testing.T) {
+ with := map[string]string{"ignored_paths": "a"}
+ runtimeInputs := map[string]string{"ignored_paths": "b"}
+
+ _ = mergeRuntimeInputs(with, runtimeInputs)
+
+ assert.Equal(t, map[string]string{"ignored_paths": "a"}, with)
+ assert.Equal(t, map[string]string{"ignored_paths": "b"}, runtimeInputs)
+}
diff --git a/pkg/attestation/crafter/materials/sigcheck/sigcheck.go b/pkg/tabular/tabular.go
similarity index 58%
rename from pkg/attestation/crafter/materials/sigcheck/sigcheck.go
rename to pkg/tabular/tabular.go
index 5c56de94a..e09e50422 100644
--- a/pkg/attestation/crafter/materials/sigcheck/sigcheck.go
+++ b/pkg/tabular/tabular.go
@@ -13,10 +13,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-// Package sigcheck parses Sysinternals sigcheck CSV/TSV output into a
-// JSON-friendly structure for policy evaluation.
-// https://learn.microsoft.com/en-us/sysinternals/downloads/sigcheck
-package sigcheck
+// Package tabular parses delimited tabular text (CSV or TSV) into a
+// header-plus-rows structure. It decodes UTF-8/UTF-16 byte-order marks and
+// auto-detects whether the delimiter is a comma or a tab, so it handles output
+// from tools like Sysinternals sigcheck as well as generic CSV/TSV files.
+package tabular
import (
"bytes"
@@ -30,25 +31,25 @@ import (
"golang.org/x/text/transform"
)
-// Report is a parsed sigcheck report: the CSV header columns and one map per
-// data row, keyed by header column.
-type Report struct {
+// Table is parsed tabular data: the header columns and one map per data row,
+// keyed by header column.
+type Table struct {
Header []string
// Rows holds one map per data row, keyed by header column. If the header
// contains duplicate column names, the last column wins.
Rows []map[string]string
}
-// Parse decodes sigcheck CSV/TSV output. It strips/decodes UTF-8 and UTF-16
+// Parse decodes delimited CSV/TSV text. It strips/decodes UTF-8 and UTF-16
// byte-order marks and auto-detects whether the delimiter is a comma or a tab.
-func Parse(raw []byte) (*Report, error) {
+func Parse(raw []byte) (*Table, error) {
data, err := decode(raw)
if err != nil {
- return nil, fmt.Errorf("decoding sigcheck output: %w", err)
+ return nil, fmt.Errorf("decoding tabular input: %w", err)
}
if len(bytes.TrimSpace(data)) == 0 {
- return nil, errors.New("empty sigcheck report")
+ return nil, errors.New("empty tabular input")
}
r := csv.NewReader(bytes.NewReader(data))
@@ -58,14 +59,14 @@ func Parse(raw []byte) (*Report, error) {
records, err := r.ReadAll()
if err != nil {
- return nil, fmt.Errorf("parsing sigcheck CSV: %w", err)
+ return nil, fmt.Errorf("parsing CSV/TSV: %w", err)
}
if len(records) == 0 {
- return nil, errors.New("sigcheck report has no header row")
+ return nil, errors.New("tabular input has no header row")
}
header := records[0]
- report := &Report{Header: header, Rows: make([]map[string]string, 0, len(records)-1)}
+ table := &Table{Header: header, Rows: make([]map[string]string, 0, len(records)-1)}
for _, rec := range records[1:] {
row := make(map[string]string, len(header))
for i, col := range header {
@@ -75,25 +76,25 @@ func Parse(raw []byte) (*Report, error) {
row[col] = ""
}
}
- report.Rows = append(report.Rows, row)
+ table.Rows = append(table.Rows, row)
}
- return report, nil
+ return table, nil
}
-// JSON marshals the report rows as a JSON array. A header-only report marshals
+// JSON marshals the table rows as a JSON array. A header-only table marshals
// to "[]".
-func (r *Report) JSON() ([]byte, error) {
- if r.Rows == nil {
+func (t *Table) JSON() ([]byte, error) {
+ if t.Rows == nil {
return []byte("[]"), nil
}
- return json.Marshal(r.Rows)
+ return json.Marshal(t.Rows)
}
// HasColumns reports whether every named column is present in the header.
-func (r *Report) HasColumns(cols ...string) bool {
- set := make(map[string]struct{}, len(r.Header))
- for _, h := range r.Header {
+func (t *Table) HasColumns(cols ...string) bool {
+ set := make(map[string]struct{}, len(t.Header))
+ for _, h := range t.Header {
set[strings.TrimSpace(h)] = struct{}{}
}
for _, c := range cols {
@@ -104,6 +105,32 @@ func (r *Report) HasColumns(cols ...string) bool {
return true
}
+// Column returns the values of the column whose header matches name
+// case-insensitively (trimming surrounding whitespace), with empty and
+// whitespace-only cells dropped, and whether such a column exists.
+func (t *Table) Column(name string) ([]string, bool) {
+ var header string
+ found := false
+ for _, h := range t.Header {
+ if strings.EqualFold(strings.TrimSpace(h), strings.TrimSpace(name)) {
+ header = h
+ found = true
+ break
+ }
+ }
+ if !found {
+ return nil, false
+ }
+
+ values := make([]string, 0, len(t.Rows))
+ for _, row := range t.Rows {
+ if v := strings.TrimSpace(row[header]); v != "" {
+ values = append(values, v)
+ }
+ }
+ return values, true
+}
+
// decode normalizes the input to UTF-8, using the BOM (if any) to detect
// UTF-16; defaults to UTF-8 when no BOM is present, stripping a UTF-8 BOM.
func decode(raw []byte) ([]byte, error) {
@@ -117,10 +144,7 @@ func decode(raw []byte) ([]byte, error) {
// output quotes every field, so a tab inside a quoted path or description must
// not be mistaken for a TSV separator. Defaults to comma.
func detectDelimiter(data []byte) rune {
- line := data
- if i := bytes.IndexByte(data, '\n'); i >= 0 {
- line = data[:i]
- }
+ line, _, _ := bytes.Cut(data, []byte{'\n'})
var commas, tabs int
inQuotes := false
diff --git a/pkg/attestation/crafter/materials/sigcheck/sigcheck_test.go b/pkg/tabular/tabular_test.go
similarity index 76%
rename from pkg/attestation/crafter/materials/sigcheck/sigcheck_test.go
rename to pkg/tabular/tabular_test.go
index da1d71529..456749d37 100644
--- a/pkg/attestation/crafter/materials/sigcheck/sigcheck_test.go
+++ b/pkg/tabular/tabular_test.go
@@ -13,13 +13,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package sigcheck_test
+package tabular_test
import (
"encoding/json"
"testing"
- "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/sigcheck"
+ "github.com/chainloop-dev/chainloop/pkg/tabular"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -89,26 +89,26 @@ func TestParse(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- report, err := sigcheck.Parse(tc.raw)
+ table, err := tabular.Parse(tc.raw)
if tc.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
- assert.Equal(t, tc.wantHeader, report.Header)
- assert.Len(t, report.Rows, tc.wantRows)
+ assert.Equal(t, tc.wantHeader, table.Header)
+ assert.Len(t, table.Rows, tc.wantRows)
for k, v := range tc.wantFirst {
- assert.Equal(t, v, report.Rows[0][k])
+ assert.Equal(t, v, table.Rows[0][k])
}
})
}
}
-func TestReportJSON(t *testing.T) {
- report, err := sigcheck.Parse([]byte("Path,Verified\nc:\\a.dll,Signed\n"))
+func TestTableJSON(t *testing.T) {
+ table, err := tabular.Parse([]byte("Path,Verified\nc:\\a.dll,Signed\n"))
require.NoError(t, err)
- out, err := report.JSON()
+ out, err := table.JSON()
require.NoError(t, err)
var rows []map[string]string
@@ -117,21 +117,35 @@ func TestReportJSON(t *testing.T) {
assert.Equal(t, "Signed", rows[0][colVerified])
}
-func TestReportJSONEmptyIsArray(t *testing.T) {
- report, err := sigcheck.Parse([]byte("Path,Verified\n"))
+func TestTableJSONEmptyIsArray(t *testing.T) {
+ table, err := tabular.Parse([]byte("Path,Verified\n"))
require.NoError(t, err)
- out, err := report.JSON()
+ out, err := table.JSON()
require.NoError(t, err)
assert.Equal(t, "[]", string(out))
}
func TestHasColumns(t *testing.T) {
- report, err := sigcheck.Parse([]byte("Path,Verified,Company\nc:\\a.dll,Signed,MS\n"))
+ table, err := tabular.Parse([]byte("Path,Verified,Company\nc:\\a.dll,Signed,MS\n"))
require.NoError(t, err)
- assert.True(t, report.HasColumns(colPath, colVerified))
- assert.False(t, report.HasColumns(colPath, "Nonexistent"))
+ assert.True(t, table.HasColumns(colPath, colVerified))
+ assert.False(t, table.HasColumns(colPath, "Nonexistent"))
+}
+
+func TestColumn(t *testing.T) {
+ table, err := tabular.Parse([]byte("Path,Verified\nc:\\a.dll,Signed\n,Unsigned\nc:\\b.dll,Signed\n"))
+ require.NoError(t, err)
+
+ // Case-insensitive match, empty cells dropped.
+ values, ok := table.Column("path")
+ require.True(t, ok)
+ assert.Equal(t, []string{"c:\\a.dll", "c:\\b.dll"}, values)
+
+ // Missing column reports not found.
+ _, ok = table.Column("Nonexistent")
+ assert.False(t, ok)
}
// utf16LE encodes s as UTF-16 little-endian with a BOM, mimicking PowerShell redirection.