From ea09a0c6a4375b6994a653b5cbace4af48877e7f Mon Sep 17 00:00:00 2001 From: miiyakumo Date: Sat, 13 Dec 2025 17:13:34 +0800 Subject: [PATCH 1/2] feat(config): add JSON schema validation and test cases for SOPS configuration Signed-off-by: miiyakumo --- Makefile | 4 + config/config_schema_test.go | 316 +++++++++++ go.mod | 2 +- schema/sops.json | 489 ++++++++++++++++++ .../test-cases/invalid-azure-missing-key.yaml | 6 + .../test-cases/invalid-kms-missing-arn.yaml | 6 + .../test-cases/invalid-shamir-threshold.yaml | 7 + schema/test-cases/invalid-stores-unknown.yaml | 6 + schema/test-cases/invalid-unknown-field.yaml | 6 + schema/test-cases/invalid-vault-version.yaml | 5 + schema/test-cases/valid-azure.yaml | 9 + schema/test-cases/valid-basic.yaml | 6 + schema/test-cases/valid-complete.yaml | 24 + schema/test-cases/valid-destination.yaml | 10 + schema/test-cases/valid-keygroups.yaml | 17 + schema/test-cases/valid-merge.yaml | 19 + schema/test-cases/valid-stores.yaml | 9 + 17 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 config/config_schema_test.go create mode 100644 schema/sops.json create mode 100644 schema/test-cases/invalid-azure-missing-key.yaml create mode 100644 schema/test-cases/invalid-kms-missing-arn.yaml create mode 100644 schema/test-cases/invalid-shamir-threshold.yaml create mode 100644 schema/test-cases/invalid-stores-unknown.yaml create mode 100644 schema/test-cases/invalid-unknown-field.yaml create mode 100644 schema/test-cases/invalid-vault-version.yaml create mode 100644 schema/test-cases/valid-azure.yaml create mode 100644 schema/test-cases/valid-basic.yaml create mode 100644 schema/test-cases/valid-complete.yaml create mode 100644 schema/test-cases/valid-destination.yaml create mode 100644 schema/test-cases/valid-keygroups.yaml create mode 100644 schema/test-cases/valid-merge.yaml create mode 100644 schema/test-cases/valid-stores.yaml diff --git a/Makefile b/Makefile index e0fbd3cbb1..3c90427fe0 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,10 @@ test: vendor gpg --import pgp/sops_functional_tests_key.asc 2>&1 1>/dev/null || exit 0 unset SOPS_AGE_KEY_FILE; unset SOPS_AGE_KEY_CMD; LANG=en_US.UTF-8 $(GO) test $(GO_TEST_FLAGS) ./... +.PHONY: test-schema +test-schema: vendor + $(GO) test -v -run TestSchema ./config + .PHONY: showcoverage showcoverage: test $(GO) tool cover -html=profile.out diff --git a/config/config_schema_test.go b/config/config_schema_test.go new file mode 100644 index 0000000000..65ede98d68 --- /dev/null +++ b/config/config_schema_test.go @@ -0,0 +1,316 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xeipuuv/gojsonschema" + "go.yaml.in/yaml/v3" +) + +// loadJSONSchema loads the JSON schema from the schema directory +func loadJSONSchema(t *testing.T) *gojsonschema.Schema { + schemaPath := filepath.Join("..", "schema", "sops.json") + schemaBytes, err := os.ReadFile(schemaPath) + require.NoError(t, err, "Failed to read JSON schema file") + + schemaLoader := gojsonschema.NewBytesLoader(schemaBytes) + schema, err := gojsonschema.NewSchema(schemaLoader) + require.NoError(t, err, "Failed to parse JSON schema") + + return schema +} + +// validateYAMLAgainstSchema validates a YAML file against the JSON schema +func validateYAMLAgainstSchema(t *testing.T, schema *gojsonschema.Schema, yamlPath string) *gojsonschema.Result { + yamlBytes, err := os.ReadFile(yamlPath) + require.NoError(t, err, "Failed to read YAML file: %s", yamlPath) + + // Parse YAML to Go object + var config interface{} + err = yaml.Unmarshal(yamlBytes, &config) + require.NoError(t, err, "Failed to parse YAML: %s", yamlPath) + + // Convert to JSON for schema validation + jsonBytes, err := json.Marshal(config) + require.NoError(t, err, "Failed to convert to JSON: %s", yamlPath) + + documentLoader := gojsonschema.NewBytesLoader(jsonBytes) + result, err := schema.Validate(documentLoader) + require.NoError(t, err, "Schema validation failed with error: %s", yamlPath) + + return result +} + +// TestSchemaValidTestCases tests that all valid test cases pass schema validation +func TestSchemaValidTestCases(t *testing.T) { + schema := loadJSONSchema(t) + + validTestCases := []string{ + "valid-basic.yaml", + "valid-complete.yaml", + "valid-keygroups.yaml", + "valid-stores.yaml", + "valid-destination.yaml", + "valid-azure.yaml", + "valid-merge.yaml", + } + + for _, testCase := range validTestCases { + t.Run(testCase, func(t *testing.T) { + testPath := filepath.Join("..", "schema", "test-cases", testCase) + result := validateYAMLAgainstSchema(t, schema, testPath) + + if !result.Valid() { + t.Errorf("Valid test case %s failed schema validation:", testCase) + for _, err := range result.Errors() { + t.Errorf(" - %s", err) + } + } + assert.True(t, result.Valid(), "Valid test case should pass schema validation") + }) + } +} + +// TestSchemaInvalidTestCases tests that all invalid test cases fail schema validation +func TestSchemaInvalidTestCases(t *testing.T) { + schema := loadJSONSchema(t) + + invalidTestCases := []string{ + "invalid-unknown-field.yaml", + "invalid-shamir-threshold.yaml", + "invalid-kms-missing-arn.yaml", + "invalid-azure-missing-key.yaml", + "invalid-vault-version.yaml", + "invalid-stores-unknown.yaml", + } + + for _, testCase := range invalidTestCases { + t.Run(testCase, func(t *testing.T) { + testPath := filepath.Join("..", "schema", "test-cases", testCase) + result := validateYAMLAgainstSchema(t, schema, testPath) + + if result.Valid() { + t.Errorf("Invalid test case %s passed schema validation but should have failed", testCase) + } + assert.False(t, result.Valid(), "Invalid test case should fail schema validation") + + // Log validation errors for debugging + t.Logf("Expected validation errors for %s:", testCase) + for _, err := range result.Errors() { + t.Logf(" - %s", err) + } + }) + } +} + +// TestSchemaAgainstRootSopsYaml tests the schema against the root .sops.yaml file +func TestSchemaAgainstRootSopsYaml(t *testing.T) { + schema := loadJSONSchema(t) + sopsYamlPath := filepath.Join("..", ".sops.yaml") + + // Check if the file exists + if _, err := os.Stat(sopsYamlPath); os.IsNotExist(err) { + t.Skip("Root .sops.yaml file does not exist") + return + } + + result := validateYAMLAgainstSchema(t, schema, sopsYamlPath) + if !result.Valid() { + t.Errorf("Root .sops.yaml failed schema validation:") + for _, err := range result.Errors() { + t.Errorf(" - %s", err) + } + } + assert.True(t, result.Valid(), "Root .sops.yaml should pass schema validation") +} + +// TestSchemaStructureMatchesConfig tests that schema structure aligns with config structs +func TestSchemaStructureMatchesConfig(t *testing.T) { + schema := loadJSONSchema(t) + + // Test that basic creation_rule fields are accepted + basicConfig := map[string]interface{}{ + "creation_rules": []map[string]interface{}{ + { + "path_regex": "\\.yaml$", + "pgp": "ABC123", + "age": "age1xxx", + "kms": "arn:aws:kms:us-east-1:123456789012:key/xxx", + }, + }, + } + + jsonBytes, err := json.Marshal(basicConfig) + require.NoError(t, err) + + documentLoader := gojsonschema.NewBytesLoader(jsonBytes) + result, err := schema.Validate(documentLoader) + require.NoError(t, err) + assert.True(t, result.Valid(), "Basic config should be valid") +} + +// TestSchemaKeyGroupsMergeField tests that the merge field in key_groups is supported +func TestSchemaKeyGroupsMergeField(t *testing.T) { + schema := loadJSONSchema(t) + + // Test key_groups with merge field + configWithMerge := map[string]interface{}{ + "creation_rules": []map[string]interface{}{ + { + "key_groups": []map[string]interface{}{ + { + "merge": []map[string]interface{}{ + { + "pgp": []string{"ABC123"}, + }, + { + "age": []string{"age1xxx"}, + }, + }, + }, + }, + }, + }, + } + + jsonBytes, err := json.Marshal(configWithMerge) + require.NoError(t, err) + + documentLoader := gojsonschema.NewBytesLoader(jsonBytes) + result, err := schema.Validate(documentLoader) + require.NoError(t, err) + + if !result.Valid() { + for _, err := range result.Errors() { + t.Logf("Validation error: %s", err) + } + } + assert.True(t, result.Valid(), "Config with merge field should be valid") +} + +// TestSchemaHCVaultFieldVariants tests both hc_vault and hc_vault_transit_uri +func TestSchemaHCVaultFieldVariants(t *testing.T) { + schema := loadJSONSchema(t) + + // Test with hc_vault (short form) + configWithHCVault := map[string]interface{}{ + "creation_rules": []map[string]interface{}{ + { + "key_groups": []map[string]interface{}{ + { + "hc_vault": []string{"https://vault.example.com/v1/transit/keys/my-key"}, + }, + }, + }, + }, + } + + jsonBytes, err := json.Marshal(configWithHCVault) + require.NoError(t, err) + + documentLoader := gojsonschema.NewBytesLoader(jsonBytes) + result, err := schema.Validate(documentLoader) + require.NoError(t, err) + assert.True(t, result.Valid(), "Config with hc_vault should be valid") + + // Test with hc_vault_transit_uri (long form) + configWithHCVaultTransit := map[string]interface{}{ + "creation_rules": []map[string]interface{}{ + { + "hc_vault_transit_uri": "https://vault.example.com/v1/transit/keys/my-key", + }, + }, + } + + jsonBytes, err = json.Marshal(configWithHCVaultTransit) + require.NoError(t, err) + + documentLoader = gojsonschema.NewBytesLoader(jsonBytes) + result, err = schema.Validate(documentLoader) + require.NoError(t, err) + assert.True(t, result.Valid(), "Config with hc_vault_transit_uri should be valid") +} + +// TestSchemaArrayAndStringFormats tests that both string and array formats are accepted +func TestSchemaArrayAndStringFormats(t *testing.T) { + schema := loadJSONSchema(t) + + // Test with string format (comma-separated) + configWithStrings := map[string]interface{}{ + "creation_rules": []map[string]interface{}{ + { + "pgp": "ABC123,DEF456", + "age": "age1xxx,age2yyy", + }, + }, + } + + jsonBytes, err := json.Marshal(configWithStrings) + require.NoError(t, err) + + documentLoader := gojsonschema.NewBytesLoader(jsonBytes) + result, err := schema.Validate(documentLoader) + require.NoError(t, err) + assert.True(t, result.Valid(), "Config with string format should be valid") + + // Test with array format + configWithArrays := map[string]interface{}{ + "creation_rules": []map[string]interface{}{ + { + "pgp": []string{"ABC123", "DEF456"}, + "age": []string{"age1xxx", "age2yyy"}, + }, + }, + } + + jsonBytes, err = json.Marshal(configWithArrays) + require.NoError(t, err) + + documentLoader = gojsonschema.NewBytesLoader(jsonBytes) + result, err = schema.Validate(documentLoader) + require.NoError(t, err) + assert.True(t, result.Valid(), "Config with array format should be valid") +} + +// TestSchemaRecreationRuleCompleteness tests that recreation_rule supports all creation_rule fields +func TestSchemaRecreationRuleCompleteness(t *testing.T) { + schema := loadJSONSchema(t) + + // Test recreation_rule with various fields + configWithRecreation := map[string]interface{}{ + "destination_rules": []map[string]interface{}{ + { + "s3_bucket": "my-bucket", + "recreation_rule": map[string]interface{}{ + "kms": "arn:aws:kms:us-east-1:123456789012:key/xxx", + "pgp": "ABC123", + "encrypted_regex": "^(password|secret)", + "shamir_threshold": 2, + "mac_only_encrypted": true, + "unencrypted_suffix": "_public", + "encrypted_comment_regex": "^encrypted:", + "unencrypted_comment_regex": "^public:", + }, + }, + }, + } + + jsonBytes, err := json.Marshal(configWithRecreation) + require.NoError(t, err) + + documentLoader := gojsonschema.NewBytesLoader(jsonBytes) + result, err := schema.Validate(documentLoader) + require.NoError(t, err) + + if !result.Valid() { + for _, err := range result.Errors() { + t.Logf("Validation error: %s", err) + } + } + assert.True(t, result.Valid(), "Recreation rule with all fields should be valid") +} diff --git a/go.mod b/go.mod index e3c5426674..2d9b295061 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 github.com/urfave/cli v1.22.17 + github.com/xeipuuv/gojsonschema v1.2.0 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 @@ -130,7 +131,6 @@ require ( github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect diff --git a/schema/sops.json b/schema/sops.json new file mode 100644 index 0000000000..0455334120 --- /dev/null +++ b/schema/sops.json @@ -0,0 +1,489 @@ +{ + "$id": "https://github.com/getsops/sops/schema/sops.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SOPS Configuration", + "description": "JSON Schema for SOPS .sops.yaml configuration files.", + "type": "object", + "additionalProperties": false, + "properties": { + "creation_rules": { + "description": "Rules for encrypting new files. The first matching rule (by path_regex) is used to determine which master keys encrypt the file.", + "items": { + "additionalProperties": false, + "description": "Rule for encrypting new files", + "properties": { + "age": { + "description": "age public key(s) - comma-separated string or array", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "aws_profile": { + "description": "AWS profile to use (works with kms field)", + "type": "string" + }, + "azure_keyvault": { + "description": "Azure Key Vault URL(s) - comma-separated string or array", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "encrypted_comment_regex": { + "description": "Only encrypt keys with comments matching this regex", + "type": "string" + }, + "encrypted_regex": { + "description": "Only encrypt keys matching this regex", + "type": "string" + }, + "encrypted_suffix": { + "description": "Only encrypt keys ending with this suffix", + "type": "string" + }, + "gcp_kms": { + "description": "GCP KMS resource ID(s) - comma-separated string or array", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "hc_vault_transit_uri": { + "description": "HashiCorp Vault transit URI(s) - comma-separated string or array", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "key_groups": { + "description": "Groups of master keys (recommended over simple kms/pgp/age fields)", + "items": { + "additionalProperties": false, + "description": "A group of master keys. At least one key from this group can decrypt the data key fragment.", + "properties": { + "age": { + "description": "age public keys or SSH public keys", + "items": { + "type": "string" + }, + "type": "array" + }, + "azure_keyvault": { + "description": "Azure Key Vault keys", + "items": { + "additionalProperties": false, + "properties": { + "key": { + "description": "Key name in Azure Key Vault", + "type": "string" + }, + "vaultUrl": { + "description": "Azure Key Vault URL", + "type": "string" + }, + "version": { + "description": "Key version (optional, uses latest if omitted)", + "type": "string" + } + }, + "required": [ + "vaultUrl", + "key" + ], + "type": "object" + }, + "type": "array" + }, + "gcp_kms": { + "description": "GCP KMS keys", + "items": { + "additionalProperties": false, + "properties": { + "resource_id": { + "description": "GCP KMS resource ID", + "type": "string" + } + }, + "required": [ + "resource_id" + ], + "type": "object" + }, + "type": "array" + }, + "hc_vault": { + "description": "HashiCorp Vault transit URIs (short form)", + "items": { + "type": "string" + }, + "type": "array" + }, + "kms": { + "description": "AWS KMS keys", + "items": { + "additionalProperties": false, + "properties": { + "arn": { + "description": "AWS KMS key ARN", + "type": "string" + }, + "aws_profile": { + "description": "AWS profile to use (optional)", + "type": "string" + }, + "context": { + "additionalProperties": { + "type": "string" + }, + "description": "KMS encryption context key-value pairs (optional)", + "type": "object" + }, + "role": { + "description": "AWS IAM role to assume (optional)", + "type": "string" + } + }, + "required": [ + "arn" + ], + "type": "object" + }, + "type": "array" + }, + "merge": { + "description": "Merge multiple key groups together (recursive)", + "items": { + "$ref": "#/properties/creation_rules/items/properties/key_groups/items" + }, + "type": "array" + }, + "pgp": { + "description": "PGP key fingerprints", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "kms": { + "description": "AWS KMS key ARN(s) - comma-separated string or array", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "mac_only_encrypted": { + "description": "Only include encrypted values in MAC calculation", + "type": "boolean" + }, + "path_regex": { + "description": "Regular expression to match file paths relative to .sops.yaml", + "type": "string" + }, + "pgp": { + "description": "PGP fingerprint(s) - comma-separated string or array", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "shamir_threshold": { + "description": "Number of key groups required to decrypt (default: number of key groups)", + "minimum": 1, + "type": "integer" + }, + "unencrypted_comment_regex": { + "description": "Don't encrypt keys with comments matching this regex", + "type": "string" + }, + "unencrypted_regex": { + "description": "Don't encrypt keys matching this regex", + "type": "string" + }, + "unencrypted_suffix": { + "description": "Don't encrypt keys ending with this suffix", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "destination_rules": { + "description": "Rules for publishing decrypted files. Used by 'sops publish' command.", + "items": { + "additionalProperties": false, + "description": "Rule for publishing decrypted files to a destination", + "properties": { + "gcs_bucket": { + "description": "GCS bucket name", + "type": "string" + }, + "gcs_prefix": { + "description": "GCS object prefix", + "type": "string" + }, + "omit_extensions": { + "description": "Omit file extensions in destination path", + "type": "boolean" + }, + "path_regex": { + "description": "Regular expression to match file paths", + "type": "string" + }, + "recreation_rule": { + "additionalProperties": false, + "description": "Re-encryption rule when publishing (same structure as creation_rule)", + "properties": { + "age": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "aws_profile": { + "description": "AWS profile to use (works with kms field)", + "type": "string" + }, + "azure_keyvault": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "encrypted_comment_regex": { + "description": "Only encrypt keys with comments matching this regex", + "type": "string" + }, + "encrypted_regex": { + "description": "Only encrypt keys matching this regex", + "type": "string" + }, + "encrypted_suffix": { + "description": "Only encrypt keys ending with this suffix", + "type": "string" + }, + "gcp_kms": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "hc_vault_transit_uri": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "key_groups": { + "description": "Groups of master keys", + "items": { + "$ref": "#/properties/creation_rules/items/properties/key_groups/items" + }, + "type": "array" + }, + "kms": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "mac_only_encrypted": { + "description": "Only include encrypted values in MAC calculation", + "type": "boolean" + }, + "pgp": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "shamir_threshold": { + "description": "Number of key groups required to decrypt", + "minimum": 1, + "type": "integer" + }, + "unencrypted_comment_regex": { + "description": "Don't encrypt keys with comments matching this regex", + "type": "string" + }, + "unencrypted_regex": { + "description": "Don't encrypt keys matching this regex", + "type": "string" + }, + "unencrypted_suffix": { + "description": "Don't encrypt keys ending with this suffix", + "type": "string" + } + }, + "type": "object" + }, + "s3_bucket": { + "description": "S3 bucket name", + "type": "string" + }, + "s3_prefix": { + "description": "S3 key prefix", + "type": "string" + }, + "vault_address": { + "description": "Vault server address", + "type": "string" + }, + "vault_kv_mount_name": { + "description": "Vault KV mount name (default: secret)", + "type": "string" + }, + "vault_kv_version": { + "description": "Vault KV version (1 or 2)", + "enum": [ + 1, + 2 + ], + "type": "integer" + }, + "vault_path": { + "description": "Vault secret path", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "stores": { + "additionalProperties": false, + "description": "Store-specific configuration for different file formats", + "properties": { + "dotenv": { + "additionalProperties": false, + "type": "object" + }, + "ini": { + "additionalProperties": false, + "type": "object" + }, + "json": { + "additionalProperties": false, + "properties": { + "indent": { + "description": "Number of spaces for JSON indentation (default: determined by input file)", + "type": "integer" + } + }, + "type": "object" + }, + "json_binary": { + "additionalProperties": false, + "properties": { + "indent": { + "description": "Number of spaces for binary JSON indentation (default: determined by input file)", + "type": "integer" + } + }, + "type": "object" + }, + "yaml": { + "additionalProperties": false, + "properties": { + "indent": { + "description": "Number of spaces for YAML indentation (default: determined by input file)", + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + } +} diff --git a/schema/test-cases/invalid-azure-missing-key.yaml b/schema/test-cases/invalid-azure-missing-key.yaml new file mode 100644 index 0000000000..8b81b43c0c --- /dev/null +++ b/schema/test-cases/invalid-azure-missing-key.yaml @@ -0,0 +1,6 @@ +# Invalid: Azure Key Vault entry missing required 'key' field +# This should fail validation because Azure KV requires both 'vaultUrl' and 'key' +creation_rules: + - key_groups: + - azure_keyvault: + - vaultUrl: https://my-vault.vault.azure.net diff --git a/schema/test-cases/invalid-kms-missing-arn.yaml b/schema/test-cases/invalid-kms-missing-arn.yaml new file mode 100644 index 0000000000..aab8d512b2 --- /dev/null +++ b/schema/test-cases/invalid-kms-missing-arn.yaml @@ -0,0 +1,6 @@ +# Invalid: KMS entry missing required 'arn' field +# This should fail validation because KMS keys require an 'arn' property +creation_rules: + - key_groups: + - kms: + - role: arn:aws:iam::123456789012:role/sops-role diff --git a/schema/test-cases/invalid-shamir-threshold.yaml b/schema/test-cases/invalid-shamir-threshold.yaml new file mode 100644 index 0000000000..6dff68c9d6 --- /dev/null +++ b/schema/test-cases/invalid-shamir-threshold.yaml @@ -0,0 +1,7 @@ +# Invalid: shamir_threshold must be >= 1 +# This should fail validation because shamir_threshold of 0 is not allowed +creation_rules: + - key_groups: + - pgp: + - FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 + shamir_threshold: 0 diff --git a/schema/test-cases/invalid-stores-unknown.yaml b/schema/test-cases/invalid-stores-unknown.yaml new file mode 100644 index 0000000000..e54ff9d1d9 --- /dev/null +++ b/schema/test-cases/invalid-stores-unknown.yaml @@ -0,0 +1,6 @@ +# Invalid: Unknown field in stores configuration +# This should fail validation because 'unknown_option' is not valid for yaml store +stores: + yaml: + indent: 2 + unknown_option: true diff --git a/schema/test-cases/invalid-unknown-field.yaml b/schema/test-cases/invalid-unknown-field.yaml new file mode 100644 index 0000000000..a0934fd8de --- /dev/null +++ b/schema/test-cases/invalid-unknown-field.yaml @@ -0,0 +1,6 @@ +# Invalid: Unknown field in creation rule +# This should fail validation because 'unknown_field' is not a valid property +creation_rules: + - path_regex: \.dev\.yaml$ + unknown_field: value + pgp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 diff --git a/schema/test-cases/invalid-vault-version.yaml b/schema/test-cases/invalid-vault-version.yaml new file mode 100644 index 0000000000..8efb45185e --- /dev/null +++ b/schema/test-cases/invalid-vault-version.yaml @@ -0,0 +1,5 @@ +# Invalid: vault_kv_version must be 1 or 2 +# This should fail validation because only KV version 1 and 2 are supported +destination_rules: + - vault_path: secrets/app + vault_kv_version: 3 diff --git a/schema/test-cases/valid-azure.yaml b/schema/test-cases/valid-azure.yaml new file mode 100644 index 0000000000..5646be8f1c --- /dev/null +++ b/schema/test-cases/valid-azure.yaml @@ -0,0 +1,9 @@ +# Valid configuration: Using Azure Key Vault +# Demonstrates Azure Key Vault URLs with version and without version, +# plus unencrypted_suffix to skip encryption of matching keys +creation_rules: + - path_regex: \.azure\.yaml$ + azure_keyvault: + - https://my-vault.vault.azure.net/keys/my-key/abc123 + - https://backup-vault.vault.azure.net/keys/backup-key + unencrypted_suffix: _public diff --git a/schema/test-cases/valid-basic.yaml b/schema/test-cases/valid-basic.yaml new file mode 100644 index 0000000000..2f64afe8f4 --- /dev/null +++ b/schema/test-cases/valid-basic.yaml @@ -0,0 +1,6 @@ +# Valid configuration: Basic creation_rules +# This demonstrates the simplest form of SOPS configuration with PGP and age keys +creation_rules: + - path_regex: \.dev\.yaml$ + pgp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 + age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/schema/test-cases/valid-complete.yaml b/schema/test-cases/valid-complete.yaml new file mode 100644 index 0000000000..6aaf6ae6c9 --- /dev/null +++ b/schema/test-cases/valid-complete.yaml @@ -0,0 +1,24 @@ +# Valid configuration: Complete example +# This demonstrates all major configuration sections: +# - creation_rules with key_groups and encrypted_regex +# - destination_rules for publishing secrets +# - stores configuration for formatting +creation_rules: + - path_regex: \.dev\.yaml$ + key_groups: + - age: + - age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + pgp: + - FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 + encrypted_regex: ^(password|secret|key) + +destination_rules: + - path_regex: \.prod\.yaml$ + vault_path: secrets/myapp + vault_address: https://vault.example.com + vault_kv_version: 2 + vault_kv_mount_name: secret + +stores: + yaml: + indent: 2 diff --git a/schema/test-cases/valid-destination.yaml b/schema/test-cases/valid-destination.yaml new file mode 100644 index 0000000000..ee0c2ef679 --- /dev/null +++ b/schema/test-cases/valid-destination.yaml @@ -0,0 +1,10 @@ +# Valid configuration: Destination rules for publishing +# Demonstrates S3 publishing with recreation_rule for re-encryption +destination_rules: + - path_regex: \.prod\.yaml$ + s3_bucket: my-secrets + s3_prefix: production/ + omit_extensions: true + recreation_rule: + kms: arn:aws:kms:us-west-2:999999999999:key/publish-key + pgp: ABCD1234 diff --git a/schema/test-cases/valid-keygroups.yaml b/schema/test-cases/valid-keygroups.yaml new file mode 100644 index 0000000000..b0f93fe0c5 --- /dev/null +++ b/schema/test-cases/valid-keygroups.yaml @@ -0,0 +1,17 @@ +# Valid configuration: Using key_groups +# Demonstrates advanced key_groups with AWS KMS (including role and context), +# PGP keys, age keys, shamir_threshold, and encrypted_regex pattern +creation_rules: + - path_regex: \.prod\.yaml$ + key_groups: + - kms: + - arn: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 + role: arn:aws:iam::123456789012:role/sops-role + context: + environment: production + pgp: + - FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 + - age: + - age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + shamir_threshold: 2 + encrypted_regex: ^(password|secret) diff --git a/schema/test-cases/valid-merge.yaml b/schema/test-cases/valid-merge.yaml new file mode 100644 index 0000000000..e5bb1122ae --- /dev/null +++ b/schema/test-cases/valid-merge.yaml @@ -0,0 +1,19 @@ +# Valid configuration: Using merge in key_groups +# Demonstrates the merge feature which allows combining multiple key groups +# This is useful for complex hierarchical key management scenarios +creation_rules: + - path_regex: \.prod\.yaml$ + key_groups: + - merge: + - pgp: + - FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 + kms: + - arn: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 + aws_profile: prod-profile + - age: + - age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + gcp_kms: + - resource_id: projects/my-project/locations/global/keyRings/sops/cryptoKeys/prod-key + pgp: + - D7229043384BCC60326C6FB9D8720D957C3D3074 + shamir_threshold: 1 diff --git a/schema/test-cases/valid-stores.yaml b/schema/test-cases/valid-stores.yaml new file mode 100644 index 0000000000..78dca034c4 --- /dev/null +++ b/schema/test-cases/valid-stores.yaml @@ -0,0 +1,9 @@ +# Valid configuration: Store-specific formatting settings +# Configures indentation for different file formats +stores: + yaml: + indent: 2 + json: + indent: 4 + json_binary: + indent: 2 From fb89aeaae6d1bc8cecab27d636f1650029702b39 Mon Sep 17 00:00:00 2001 From: miiyakumo Date: Sat, 13 Dec 2025 17:24:41 +0800 Subject: [PATCH 2/2] feat(docs): add JSON Schema validation section for .sops.yaml configuration Signed-off-by: miiyakumo --- README.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.rst b/README.rst index aba7dea707..aa9614e63e 100644 --- a/README.rst +++ b/README.rst @@ -849,6 +849,22 @@ Creating a new file with the right keys is now as simple as Note that the configuration file is ignored when KMS or PGP parameters are passed on the SOPS command line or in environment variables. +Validating .sops.yaml with JSON Schema +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +SOPS provides a JSON Schema for validating ``.sops.yaml`` configuration files. +The schema is located at ``schema/sops.json`` in the repository and can be used +with editors and validation tools to catch configuration errors early. + +**Using with YAML Language Server:** + +You can add a schema reference directly in your ``.sops.yaml`` file: + +.. code:: yaml + + # yaml-language-server: $schema=https://raw.githubusercontent.com/getsops/sops/main/schema/sops.json + + Specify a different GPG executable ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~