diff --git a/app/cli/cmd/attestation_add.go b/app/cli/cmd/attestation_add.go index e4e377db4..c8b1720cb 100644 --- a/app/cli/cmd/attestation_add.go +++ b/app/cli/cmd/attestation_add.go @@ -38,6 +38,7 @@ func newAttestationAddCmd() *cobra.Command { var name, value, kind string var artifactCASConn *grpc.ClientConn var annotationsFlag []string + var noStrictValidation bool // OCI registry credentials can be passed as flags or environment variables var registryServer, registryUsername, registryPassword string @@ -76,6 +77,7 @@ func newAttestationAddCmd() *cobra.Command { RegistryUsername: registryUsername, RegistryPassword: registryPassword, LocalStatePath: attestationLocalStatePath, + NoStrictValidation: noStrictValidation, }, ) if err != nil { @@ -142,6 +144,7 @@ func newAttestationAddCmd() *cobra.Command { cmd.Flags().StringSliceVar(&annotationsFlag, "annotation", nil, "additional annotation in the format of key=value") 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 SBOM_CYCLONEDX_JSON materials") // Optional OCI registry credentials cmd.Flags().StringVar(®istryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName)) diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 4d3b1763b..591785762 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -202,6 +202,7 @@ Options -h, --help help for add --kind string kind of the material to be recorded: ["ARTIFACT" "ATTESTATION" "BLACKDUCK_SCA_JSON" "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" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"] --name string name of the material as shown in the contract +--no-strict-validation skip strict schema validation for SBOM_CYCLONEDX_JSON materials --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) diff --git a/app/cli/internal/policydevel/eval.go b/app/cli/internal/policydevel/eval.go index 8f1ab3100..998845897 100644 --- a/app/cli/internal/policydevel/eval.go +++ b/app/cli/internal/policydevel/eval.go @@ -199,7 +199,7 @@ func craft(materialPath string, kind v1.CraftingSchema_Material_MaterialType, na Name: name, } - m, err := materials.Craft(context.Background(), materialSchema, materialPath, backend, nil, logger) + m, err := materials.Craft(context.Background(), materialSchema, materialPath, backend, nil, logger, nil) if err != nil { return nil, fmt.Errorf("failed to craft material (kind=%s): %w", kind.String(), err) } diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index eb1c278b0..082db2322 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -36,6 +36,8 @@ type AttestationAddOpts struct { // OCI registry credentials used for CONTAINER_IMAGE material type RegistryServer, RegistryUsername, RegistryPassword string LocalStatePath string + // NoStrictValidation skips strict schema validation + NoStrictValidation bool } type newCrafterOpts struct { @@ -59,6 +61,9 @@ func NewAttestationAdd(cfg *AttestationAddOpts) (*AttestationAdd, error) { cfg.Logger.Debug().Str("server", cfg.RegistryServer).Str("username", cfg.RegistryUsername).Msg("using OCI registry credentials") opts = append(opts, crafter.WithOCIAuth(cfg.RegistryServer, cfg.RegistryUsername, cfg.RegistryPassword)) } + if cfg.NoStrictValidation { + opts = append(opts, crafter.WithNoStrictValidation(cfg.NoStrictValidation)) + } return &AttestationAdd{ ActionsOpts: cfg.ActionsOpts, diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index cbe85aba3..7d845ea74 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -73,6 +73,9 @@ type Crafter struct { // attestation client is used to load chainloop policies attClient v1.AttestationServiceClient + + // noStrictValidation skips strict schema validation + noStrictValidation bool } type VersionedCraftingState struct { @@ -118,6 +121,13 @@ func WithOCIAuth(server, username, password string) NewOpt { } } +func WithNoStrictValidation(noStrictValidation bool) NewOpt { + return func(c *Crafter) error { + c.noStrictValidation = noStrictValidation + return nil + } +} + // Create a completely new crafter func NewCrafter(stateManager StateManager, attClient v1.AttestationServiceClient, opts ...NewOpt) (*Crafter, error) { noopLogger := zerolog.Nop() @@ -671,7 +681,9 @@ 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) { // 3- Craft resulting material - mt, err := materials.Craft(context.Background(), m, value, casBackend, c.ociRegistryAuth, c.Logger) + mt, err := materials.Craft(context.Background(), m, value, casBackend, c.ociRegistryAuth, c.Logger, &materials.CraftingOpts{ + NoStrictValidation: c.noStrictValidation, + }) if err != nil { return nil, err } diff --git a/pkg/attestation/crafter/materials/cyclonedxjson.go b/pkg/attestation/crafter/materials/cyclonedxjson.go index 658d516cb..102f062f1 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson.go @@ -39,10 +39,28 @@ const ( ) type CyclonedxJSONCrafter struct { - backend *casclient.CASBackend + backend *casclient.CASBackend + noStrictValidation bool *crafterCommon } +// CycloneDXCraftOpt is a functional option for CyclonedxJSONCrafter +type CycloneDXCraftOpt func(*CyclonedxJSONCrafter) + +// WithCycloneDXNoStrictValidation sets the noStrictValidation option +func WithCycloneDXNoStrictValidation(noStrict bool) CycloneDXCraftOpt { + return func(c *CyclonedxJSONCrafter) { + c.noStrictValidation = noStrict + } +} + +// cyclonedxRequiredFields checks the three required top-level fields per CycloneDX spec +type cyclonedxRequiredFields struct { + BOMFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + Version int `json:"version"` +} + // cyclonedxDoc internal struct to unmarshall the incoming CycloneDX JSON type cyclonedxDoc struct { SpecVersion string `json:"specVersion"` @@ -78,15 +96,21 @@ type cyclonedxMetadataV15 struct { Component cyclonedxComponent `json:"component"` } -func NewCyclonedxJSONCrafter(materialSchema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*CyclonedxJSONCrafter, error) { +func NewCyclonedxJSONCrafter(materialSchema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger, opts ...CycloneDXCraftOpt) (*CyclonedxJSONCrafter, error) { if materialSchema.Type != schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON { return nil, fmt.Errorf("material type is not cyclonedx json") } - return &CyclonedxJSONCrafter{ + c := &CyclonedxJSONCrafter{ backend: backend, crafterCommon: &crafterCommon{logger: l, input: materialSchema}, - }, nil + } + + for _, opt := range opts { + opt(c) + } + + return c, nil } func (i *CyclonedxJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Attestation_Material, error) { @@ -95,6 +119,17 @@ func (i *CyclonedxJSONCrafter) Craft(ctx context.Context, filePath string) (*api return nil, fmt.Errorf("can't open the file: %w", err) } + var required cyclonedxRequiredFields + if err := json.Unmarshal(f, &required); err != nil { + i.logger.Debug().Err(err).Msg("error decoding file") + return nil, fmt.Errorf("invalid cyclonedx sbom file: %w", ErrInvalidMaterialType) + } + + if required.BOMFormat != "CycloneDX" || required.SpecVersion == "" || required.Version < 1 { + i.logger.Debug().Str("bomFormat", required.BOMFormat).Str("specVersion", required.SpecVersion).Int("version", required.Version).Msg("missing required CycloneDX fields") + return nil, fmt.Errorf("invalid cyclonedx sbom file: %w", ErrInvalidMaterialType) + } + var v interface{} if err := json.Unmarshal(f, &v); err != nil { i.logger.Debug().Err(err).Msg("error decoding file") @@ -102,10 +137,14 @@ func (i *CyclonedxJSONCrafter) Craft(ctx context.Context, filePath string) (*api } // Setting the version to empty string to validate against the latest version of the schema - err = schemavalidators.ValidateCycloneDX(v, "") - if err != nil { - i.logger.Debug().Err(err).Msgf("error decoding file: %#v", err) - return nil, fmt.Errorf("invalid cyclonedx sbom file: %w", ErrInvalidMaterialType) + if err := schemavalidators.ValidateCycloneDX(v, ""); err != nil { + if i.noStrictValidation { + i.logger.Warn().Err(err).Msg("error decoding file, strict validation disabled, continuing") + } else { + i.logger.Debug().Err(err).Msg("error decoding file") + i.logger.Info().Msg("you can disable strict validation to skip schema validation") + return nil, fmt.Errorf("invalid cyclonedx sbom file: %w", ErrInvalidMaterialType) + } } m, err := uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger) diff --git a/pkg/attestation/crafter/materials/cyclonedxjson_test.go b/pkg/attestation/crafter/materials/cyclonedxjson_test.go index 912f0f919..5112407ea 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson_test.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson_test.go @@ -225,6 +225,72 @@ func TestCyclonedxJSONCraft(t *testing.T) { } } +func TestCycloneDXJSONCraftNoStrictValidation(t *testing.T) { + testCases := []struct { + name string + filePath string + noStrictValidation bool + wantErr string + }{ + { + name: "invalid schema without skip flag fails", + filePath: "./testdata/sbom.cyclonedx-invalid-schema.json", + noStrictValidation: false, + wantErr: "invalid cyclonedx sbom file", + }, + { + name: "invalid schema with skip flag succeeds", + filePath: "./testdata/sbom.cyclonedx-invalid-schema.json", + noStrictValidation: true, + wantErr: "", + }, + { + name: "non-cyclonedx file fails even with skip flag", + filePath: "./testdata/random.json", + noStrictValidation: true, + wantErr: "invalid cyclonedx sbom file", + }, + { + name: "valid file works without skip flag", + filePath: "./testdata/sbom.cyclonedx.json", + noStrictValidation: false, + wantErr: "", + }, + } + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_SBOM_CYCLONEDX_JSON, + } + l := zerolog.Nop() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ast := assert.New(t) + // Mock uploader + uploader := mUploader.NewUploader(t) + if tc.wantErr == "" { + uploader.On("UploadFile", context.TODO(), tc.filePath). + Return(&casclient.UpDownStatus{}, nil) + } + + backend := &casclient.CASBackend{Uploader: uploader} + crafter, err := materials.NewCyclonedxJSONCrafter(schema, backend, &l, + materials.WithCycloneDXNoStrictValidation(tc.noStrictValidation)) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + if tc.wantErr != "" { + ast.ErrorContains(err, tc.wantErr) + return + } + + require.NoError(t, err) + ast.Equal(contractAPI.CraftingSchema_Material_SBOM_CYCLONEDX_JSON.String(), got.MaterialType.String()) + }) + } +} + func TestCycloneDXJSONCraft_SkipUpload(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 123cbacd9..a8c92a090 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -205,7 +205,13 @@ type Craftable interface { Craft(ctx context.Context, value string) (*api.Attestation_Material, error) } -func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Material, value string, casBackend *casclient.CASBackend, ociAuth authn.Keychain, logger *zerolog.Logger) (*api.Attestation_Material, error) { +// CraftingOpts contains options for crafting materials +type CraftingOpts struct { + NoStrictValidation bool +} + +//nolint:gocyclo +func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Material, value string, casBackend *casclient.CASBackend, ociAuth authn.Keychain, logger *zerolog.Logger, opts *CraftingOpts) (*api.Attestation_Material, error) { var crafter Craftable var err error @@ -218,6 +224,10 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia return nil, fmt.Errorf("validating material: %w", err) } + if opts == nil { + opts = &CraftingOpts{} + } + switch materialSchema.Type { case schemaapi.CraftingSchema_Material_STRING: crafter, err = NewStringCrafter(materialSchema) @@ -226,7 +236,7 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia case schemaapi.CraftingSchema_Material_ARTIFACT: crafter, err = NewArtifactCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON: - crafter, err = NewCyclonedxJSONCrafter(materialSchema, casBackend, logger) + crafter, err = NewCyclonedxJSONCrafter(materialSchema, casBackend, logger, WithCycloneDXNoStrictValidation(opts.NoStrictValidation)) case schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON: crafter, err = NewSPDXJSONCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_JUNIT_XML: diff --git a/pkg/attestation/crafter/materials/materials_test.go b/pkg/attestation/crafter/materials/materials_test.go index 7d323bb64..f7aaafdab 100644 --- a/pkg/attestation/crafter/materials/materials_test.go +++ b/pkg/attestation/crafter/materials/materials_test.go @@ -40,7 +40,7 @@ func TestCraft(t *testing.T) { }, } - got, err := materials.Craft(context.TODO(), schema, "test-value", nil, nil, nil) + got, err := materials.Craft(context.TODO(), schema, "test-value", nil, nil, nil, nil) require.NoError(t, err) assert.Equal(contractAPI.CraftingSchema_Material_STRING, got.MaterialType) assert.False(got.UploadedToCas) diff --git a/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-invalid-schema.json b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-invalid-schema.json new file mode 100644 index 000000000..b0f7011bf --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-invalid-schema.json @@ -0,0 +1,7 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "invalidField": "this field is not valid according to the schema", + "components": "should be an array but is a string" +}