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.