Skip to content

Commit fbfab82

Browse files
committed
adding support for all sops supported file types
1 parent cd2d8e9 commit fbfab82

11 files changed

Lines changed: 387 additions & 8 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313

1414
# Build artifacts
1515
sops-cop
16+
sops-cop_*
1617
/bin/
1718
/dist/
19+
/dist-local/
1820

1921
# Dependency directories (if used)
2022
vendor/

.sops.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
creation_rules:
22
- path_regex: '(^|.*/).*secret.*\.ya?ml$'
33
encrypted_regex: '^(data|stringData)$'
4+
- path_regex: '(^|.*/).*secret.*\.json$'
5+
encrypted_regex: ''
6+
- path_regex: '(^|.*/).*secret.*\.env$'
7+
encrypted_regex: ''
8+
- path_regex: '(^|.*/).*secret.*\.ini$'
9+
encrypted_regex: ''

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ SOPS-Cop is a CLI tool to enforce SOPS encryption rules without requiring the SO
1010
## How it works
1111

1212
- Discovers your existing SOPS configuration and verifies encryption rules are followed.
13-
- Reports each unencrypted key path to `stderr` with file path and line:column location.
13+
- Supports YAML, JSON, ENV, and INI files when matched by `.sops.yaml` creation rules.
14+
- Reports each unencrypted key path to `stderr` with file path and location details (line:column for YAML; path-only fallback for other formats).
1415

1516
## Exit codes
1617

1718
- `0`: all checked values are encrypted
1819
- `2`: invalid arguments (for example, unresolvable target path)
1920
- `3`: file read error (for example, file missing or permission denied)
20-
- `4`: invalid YAML input
21+
- `4`: invalid input for the matched file format (YAML/JSON/ENV/INI)
2122
- `5`: one or more unencrypted values were found
2223
- `6`: `.sops.yaml` config error (for example, invalid regex)
2324

@@ -37,7 +38,7 @@ go install github.com/binbashing/sops-cop@latest
3738

3839
### Option 2: prebuilt release binary (no Go required)
3940

40-
Download the correct archive from the GitHub Releases page for your OS/arch, extract it, and place `sops-cop` on your `PATH`.
41+
Download the correct binary from the GitHub Releases page for your OS/arch and place `sops-cop` on your `PATH`.
4142

4243
## Build
4344

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,5 @@ require (
115115
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
116116
google.golang.org/grpc v1.71.1 // indirect
117117
google.golang.org/protobuf v1.36.6 // indirect
118+
gopkg.in/ini.v1 v1.67.0 // indirect
118119
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,8 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
311311
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
312312
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
313313
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
314+
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
315+
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
314316
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
315317
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
316318
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import (
1313
sopsformats "github.com/getsops/sops/v3/cmd/sops/formats"
1414
sopsconfig "github.com/getsops/sops/v3/config"
1515
sopsstores "github.com/getsops/sops/v3/stores"
16+
sopsdotenv "github.com/getsops/sops/v3/stores/dotenv"
17+
sopsini "github.com/getsops/sops/v3/stores/ini"
18+
sopsjson "github.com/getsops/sops/v3/stores/json"
1619
sopsyaml "github.com/getsops/sops/v3/stores/yaml"
1720
"gopkg.in/yaml.v3"
1821
)
@@ -51,7 +54,7 @@ func main() {
5154
func usage() {
5255
out := flag.CommandLine.Output()
5356
fmt.Fprintf(out, "sops-cop %s\n\n", version)
54-
fmt.Fprintln(out, "Validates that YAML values are encrypted according to .sops.yaml rules.")
57+
fmt.Fprintln(out, "Validates YAML/JSON/ENV/INI values are encrypted according to .sops.yaml rules.")
5558
fmt.Fprintln(out, "Usage:")
5659
fmt.Fprintln(out, " sops-cop [-target <path-inside-project>]")
5760
fmt.Fprintln(out)
@@ -125,8 +128,8 @@ func validateProject(config *SopsConfig, configDir string, stderr io.Writer) int
125128
return nil
126129
}
127130

128-
if !sopsformats.IsYAMLFile(path) {
129-
fmt.Fprintf(stderr, "warning: skipping non-YAML file matched by path_regex: %s\n", path)
131+
if !isSupportedStructuredFile(path) {
132+
fmt.Fprintf(stderr, "warning: skipping unsupported file matched by path_regex: %s\n", path)
130133
return nil
131134
}
132135

@@ -163,9 +166,9 @@ func validateFileWithRule(filePath string, rule *sopsconfig.Config, stderr io.Wr
163166
return exitFileReadError, 0
164167
}
165168

166-
failures, err := validateYAMLContent(data, rule)
169+
failures, formatName, err := validateContentForFile(filePath, data, rule)
167170
if err != nil {
168-
fmt.Fprintf(stderr, "error: invalid YAML: %v\n", err)
171+
fmt.Fprintf(stderr, "error: invalid %s: %v\n", formatName, err)
169172
return exitInvalidYAML, 0
170173
}
171174

@@ -179,6 +182,90 @@ func validateFileWithRule(filePath string, rule *sopsconfig.Config, stderr io.Wr
179182
return exitSuccess, 0
180183
}
181184

185+
// plainFileLoader is implemented by SOPS format stores that load plaintext into tree branches.
186+
type plainFileLoader interface {
187+
LoadPlainFile([]byte) (sops.TreeBranches, error)
188+
}
189+
190+
func isSupportedStructuredFile(path string) bool {
191+
return sopsformats.IsYAMLFile(path) ||
192+
sopsformats.IsJSONFile(path) ||
193+
sopsformats.IsEnvFile(path) ||
194+
sopsformats.IsIniFile(path)
195+
}
196+
197+
func formatNameForPath(path string) string {
198+
switch {
199+
case sopsformats.IsYAMLFile(path):
200+
return "YAML"
201+
case sopsformats.IsJSONFile(path):
202+
return "JSON"
203+
case sopsformats.IsEnvFile(path):
204+
return "ENV"
205+
case sopsformats.IsIniFile(path):
206+
return "INI"
207+
default:
208+
return "file"
209+
}
210+
}
211+
212+
func validateContentForFile(filePath string, data []byte, rule *sopsconfig.Config) ([]string, string, error) {
213+
if sopsformats.IsYAMLFile(filePath) {
214+
failures, err := validateYAMLContent(data, rule)
215+
return failures, "YAML", err
216+
}
217+
218+
if strings.TrimSpace(string(data)) == "" {
219+
return []string{}, formatNameForPath(filePath), nil
220+
}
221+
222+
switch {
223+
case sopsformats.IsJSONFile(filePath):
224+
failures, err := validateStructuredContent(data, rule, sopsjson.NewStore(&sopsconfig.JSONStoreConfig{}))
225+
return failures, "JSON", err
226+
case sopsformats.IsEnvFile(filePath):
227+
failures, err := validateStructuredContent(data, rule, sopsdotenv.NewStore(&sopsconfig.DotenvStoreConfig{}))
228+
return failures, "ENV", err
229+
case sopsformats.IsIniFile(filePath):
230+
failures, err := validateStructuredContent(data, rule, sopsini.NewStore(&sopsconfig.INIStoreConfig{}))
231+
return failures, "INI", err
232+
default:
233+
return []string{}, formatNameForPath(filePath), nil
234+
}
235+
}
236+
237+
func validateStructuredContent(data []byte, rule *sopsconfig.Config, store plainFileLoader) ([]string, error) {
238+
branchesForValidation, err := store.LoadPlainFile(data)
239+
if err != nil {
240+
return []string{}, err
241+
}
242+
243+
if len(branchesForValidation) == 0 {
244+
return []string{}, nil
245+
}
246+
247+
branchesForSelection, err := store.LoadPlainFile(data)
248+
if err != nil {
249+
return []string{}, err
250+
}
251+
252+
encryptedPaths, err := computeSOPSSelectedPathsFromBranches(branchesForSelection, rule)
253+
if err != nil {
254+
return []string{}, err
255+
}
256+
257+
var failures []string
258+
for _, branch := range branchesForValidation {
259+
walkTreeValue(branch, nil, &failures, encryptedPaths)
260+
}
261+
262+
if failures == nil {
263+
failures = []string{}
264+
}
265+
266+
return failures, nil
267+
}
268+
182269
// validateYAMLContent parses YAML bytes and returns locations of unencrypted values that should be encrypted.
183270
func validateYAMLContent(data []byte, rule *sopsconfig.Config) ([]string, error) {
184271
// Parse YAML first to detect empty or comment-only files before
@@ -288,6 +375,11 @@ func computeSOPSSelectedPaths(data []byte, rule *sopsconfig.Config) (map[string]
288375
return nil, err
289376
}
290377

378+
return computeSOPSSelectedPathsFromBranches(branches, rule)
379+
}
380+
381+
func computeSOPSSelectedPathsFromBranches(branches sops.TreeBranches, rule *sopsconfig.Config) (map[string]struct{}, error) {
382+
291383
// Apply the SOPS default: when no selector is specified, keys ending in
292384
// "_unencrypted" are left as plaintext.
293385
unencryptedSuffix := rule.UnencryptedSuffix
@@ -322,6 +414,38 @@ func computeSOPSSelectedPaths(data []byte, rule *sopsconfig.Config) (map[string]
322414
return selected, nil
323415
}
324416

417+
func walkTreeValue(value interface{}, path []string, failures *[]string, encryptedPaths map[string]struct{}) {
418+
switch typed := value.(type) {
419+
case sops.TreeBranch:
420+
for _, item := range typed {
421+
key := fmt.Sprint(item.Key)
422+
if key == sopsstores.SopsMetadataKey {
423+
continue
424+
}
425+
nextPath := appendPath(path, key)
426+
walkTreeValue(item.Value, nextPath, failures, encryptedPaths)
427+
}
428+
429+
case []interface{}:
430+
for i, item := range typed {
431+
nextPath := appendPath(path, strconv.Itoa(i))
432+
walkTreeValue(item, nextPath, failures, encryptedPaths)
433+
}
434+
435+
case string:
436+
if _, shouldEncrypt := encryptedPaths[joinPath(path)]; shouldEncrypt && !strings.HasPrefix(typed, encryptedPrefix) {
437+
msg := fmt.Sprintf("unencrypted value found at '%s'", joinPath(path))
438+
*failures = append(*failures, msg)
439+
}
440+
441+
default:
442+
if _, shouldEncrypt := encryptedPaths[joinPath(path)]; shouldEncrypt {
443+
msg := fmt.Sprintf("unencrypted value found at '%s'", joinPath(path))
444+
*failures = append(*failures, msg)
445+
}
446+
}
447+
}
448+
325449
func collectSelectedPaths(value interface{}, path []string, selected map[string]struct{}) {
326450
switch typed := value.(type) {
327451
case sops.TreeBranch:

main_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,3 +567,144 @@ func TestExampleSecretFixtureWithSopsConfig(t *testing.T) {
567567
}
568568
})
569569
}
570+
571+
func TestRunSupportsStructuredFormats(t *testing.T) {
572+
t.Run("json plaintext value fails", func(t *testing.T) {
573+
tempDir := t.TempDir()
574+
sopsConfig := `creation_rules:
575+
- path_regex: ".*\\.json$"
576+
encrypted_regex: ""
577+
`
578+
if err := os.WriteFile(filepath.Join(tempDir, ".sops.yaml"), []byte(sopsConfig), 0o600); err != nil {
579+
t.Fatalf("write .sops.yaml: %v", err)
580+
}
581+
if err := os.WriteFile(filepath.Join(tempDir, "secrets.json"), []byte(`{"password":"plaintext"}`), 0o600); err != nil {
582+
t.Fatalf("write secrets.json: %v", err)
583+
}
584+
585+
var stderr bytes.Buffer
586+
gotExitCode := run(tempDir, &stderr)
587+
if gotExitCode != exitUnencryptedValue {
588+
t.Fatalf("run() exit code = %d, want %d; stderr=%q", gotExitCode, exitUnencryptedValue, stderr.String())
589+
}
590+
if !strings.Contains(stderr.String(), "secrets.json") || !strings.Contains(stderr.String(), "unencrypted value found") {
591+
t.Fatalf("run() stderr = %q, want json violation output", stderr.String())
592+
}
593+
})
594+
595+
t.Run("json encrypted value passes", func(t *testing.T) {
596+
tempDir := t.TempDir()
597+
sopsConfig := `creation_rules:
598+
- path_regex: ".*\\.json$"
599+
encrypted_regex: ""
600+
`
601+
if err := os.WriteFile(filepath.Join(tempDir, ".sops.yaml"), []byte(sopsConfig), 0o600); err != nil {
602+
t.Fatalf("write .sops.yaml: %v", err)
603+
}
604+
if err := os.WriteFile(filepath.Join(tempDir, "secrets.json"), []byte(`{"password":"ENC[AES256_GCM,data:abc]"}`), 0o600); err != nil {
605+
t.Fatalf("write secrets.json: %v", err)
606+
}
607+
608+
var stderr bytes.Buffer
609+
gotExitCode := run(tempDir, &stderr)
610+
if gotExitCode != exitSuccess {
611+
t.Fatalf("run() exit code = %d, want %d; stderr=%q", gotExitCode, exitSuccess, stderr.String())
612+
}
613+
if !strings.Contains(stderr.String(), "All files compliant") {
614+
t.Fatalf("run() stderr = %q, want success summary", stderr.String())
615+
}
616+
})
617+
618+
t.Run("dotenv plaintext value fails", func(t *testing.T) {
619+
tempDir := t.TempDir()
620+
sopsConfig := `creation_rules:
621+
- path_regex: ".*\\.env$"
622+
encrypted_regex: ""
623+
`
624+
if err := os.WriteFile(filepath.Join(tempDir, ".sops.yaml"), []byte(sopsConfig), 0o600); err != nil {
625+
t.Fatalf("write .sops.yaml: %v", err)
626+
}
627+
if err := os.WriteFile(filepath.Join(tempDir, "app.env"), []byte("PASSWORD=plaintext\n"), 0o600); err != nil {
628+
t.Fatalf("write app.env: %v", err)
629+
}
630+
631+
var stderr bytes.Buffer
632+
gotExitCode := run(tempDir, &stderr)
633+
if gotExitCode != exitUnencryptedValue {
634+
t.Fatalf("run() exit code = %d, want %d; stderr=%q", gotExitCode, exitUnencryptedValue, stderr.String())
635+
}
636+
if !strings.Contains(stderr.String(), "app.env") || !strings.Contains(stderr.String(), "unencrypted value found") {
637+
t.Fatalf("run() stderr = %q, want env violation output", stderr.String())
638+
}
639+
})
640+
641+
t.Run("dotenv encrypted value passes", func(t *testing.T) {
642+
tempDir := t.TempDir()
643+
sopsConfig := `creation_rules:
644+
- path_regex: ".*\\.env$"
645+
encrypted_regex: ""
646+
`
647+
if err := os.WriteFile(filepath.Join(tempDir, ".sops.yaml"), []byte(sopsConfig), 0o600); err != nil {
648+
t.Fatalf("write .sops.yaml: %v", err)
649+
}
650+
if err := os.WriteFile(filepath.Join(tempDir, "app.env"), []byte("PASSWORD=ENC[AES256_GCM,data:abc]\n"), 0o600); err != nil {
651+
t.Fatalf("write app.env: %v", err)
652+
}
653+
654+
var stderr bytes.Buffer
655+
gotExitCode := run(tempDir, &stderr)
656+
if gotExitCode != exitSuccess {
657+
t.Fatalf("run() exit code = %d, want %d; stderr=%q", gotExitCode, exitSuccess, stderr.String())
658+
}
659+
if !strings.Contains(stderr.String(), "All files compliant") {
660+
t.Fatalf("run() stderr = %q, want success summary", stderr.String())
661+
}
662+
})
663+
664+
t.Run("ini plaintext value fails", func(t *testing.T) {
665+
tempDir := t.TempDir()
666+
sopsConfig := `creation_rules:
667+
- path_regex: ".*\\.ini$"
668+
encrypted_regex: ""
669+
`
670+
if err := os.WriteFile(filepath.Join(tempDir, ".sops.yaml"), []byte(sopsConfig), 0o600); err != nil {
671+
t.Fatalf("write .sops.yaml: %v", err)
672+
}
673+
if err := os.WriteFile(filepath.Join(tempDir, "app.ini"), []byte("[db]\npassword=plaintext\n"), 0o600); err != nil {
674+
t.Fatalf("write app.ini: %v", err)
675+
}
676+
677+
var stderr bytes.Buffer
678+
gotExitCode := run(tempDir, &stderr)
679+
if gotExitCode != exitUnencryptedValue {
680+
t.Fatalf("run() exit code = %d, want %d; stderr=%q", gotExitCode, exitUnencryptedValue, stderr.String())
681+
}
682+
if !strings.Contains(stderr.String(), "app.ini") || !strings.Contains(stderr.String(), "unencrypted value found") {
683+
t.Fatalf("run() stderr = %q, want ini violation output", stderr.String())
684+
}
685+
})
686+
687+
t.Run("ini encrypted value passes", func(t *testing.T) {
688+
tempDir := t.TempDir()
689+
sopsConfig := `creation_rules:
690+
- path_regex: ".*\\.ini$"
691+
encrypted_regex: ""
692+
`
693+
if err := os.WriteFile(filepath.Join(tempDir, ".sops.yaml"), []byte(sopsConfig), 0o600); err != nil {
694+
t.Fatalf("write .sops.yaml: %v", err)
695+
}
696+
if err := os.WriteFile(filepath.Join(tempDir, "app.ini"), []byte("[db]\npassword=ENC[AES256_GCM,data:abc]\n"), 0o600); err != nil {
697+
t.Fatalf("write app.ini: %v", err)
698+
}
699+
700+
var stderr bytes.Buffer
701+
gotExitCode := run(tempDir, &stderr)
702+
if gotExitCode != exitSuccess {
703+
t.Fatalf("run() exit code = %d, want %d; stderr=%q", gotExitCode, exitSuccess, stderr.String())
704+
}
705+
if !strings.Contains(stderr.String(), "All files compliant") {
706+
t.Fatalf("run() stderr = %q, want success summary", stderr.String())
707+
}
708+
})
709+
710+
}

0 commit comments

Comments
 (0)