@@ -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() {
5154func 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.
183270func 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+
325449func collectSelectedPaths (value interface {}, path []string , selected map [string ]struct {}) {
326450 switch typed := value .(type ) {
327451 case sops.TreeBranch :
0 commit comments