diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 09d0b8cf4..321a800a5 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -26,6 +26,7 @@ import ( "github.com/getsops/sops/v3/cmd/sops/codes" "github.com/getsops/sops/v3/cmd/sops/common" "github.com/getsops/sops/v3/cmd/sops/subcommand/exec" + filestatuscmd "github.com/getsops/sops/v3/cmd/sops/subcommand/filestatus" "github.com/getsops/sops/v3/cmd/sops/subcommand/groups" keyservicecmd "github.com/getsops/sops/v3/cmd/sops/subcommand/keyservice" publishcmd "github.com/getsops/sops/v3/cmd/sops/subcommand/publish" @@ -428,6 +429,38 @@ func main() { return nil }, }, + { + Name: "filestatus", + Usage: "check the status of the file, returning encryption status", + ArgsUsage: `file`, + Flags: []cli.Flag{}, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return common.NewExitError("Error: no file specified", codes.NoFileSpecified) + } + + fileName := c.Args()[0] + inputStore := inputStore(c, fileName) + opts := filestatuscmd.Opts{ + InputStore: inputStore, + InputPath: fileName, + } + + status, err := filestatuscmd.FileStatus(opts) + if err != nil { + return err + } + + json, err := encodingjson.Marshal(status) + if err != nil { + return common.NewExitError(err, codes.ErrorGeneric) + } + + fmt.Println(string(json)) + + return nil + }, + }, { Name: "groups", Usage: "modify the groups on a SOPS file", diff --git a/cmd/sops/subcommand/filestatus/filestatus.go b/cmd/sops/subcommand/filestatus/filestatus.go new file mode 100644 index 000000000..3d40c96fa --- /dev/null +++ b/cmd/sops/subcommand/filestatus/filestatus.go @@ -0,0 +1,60 @@ +package filestatus + +import ( + "fmt" + + "github.com/getsops/sops/v3" + "github.com/getsops/sops/v3/cmd/sops/common" +) + +// Opts represent the input options for FileStatus +type Opts struct { + InputStore sops.Store + InputPath string +} + +// Status represents the status of a file +type Status struct { + // Encrypted represents whether the file provided is encrypted by SOPS + Encrypted bool `json:"encrypted"` +} + +// FileStatus checks encryption status of a file +func FileStatus(opts Opts) (Status, error) { + encrypted, err := cfs(opts.InputStore, opts.InputPath) + if err != nil { + return Status{}, fmt.Errorf("cannot check file status: %w", err) + } + return Status{Encrypted: encrypted}, nil +} + +// cfs checks and reports on file encryption status. +// +// It tries to decrypt the input file with the provided store. +// It returns true if the file contains sops metadata, false +// if it doesn't or Version or MessageAuthenticationCode are +// not found. +// It reports any error encountered different from +// sops.MetadataNotFound, as that is used to detect a sops +// encrypted file. +func cfs(s sops.Store, inputpath string) (bool, error) { + tree, err := common.LoadEncryptedFile(s, inputpath) + if err != nil && err == sops.MetadataNotFound { + return false, nil + } + if err != nil { + return false, fmt.Errorf("cannot load encrypted file: %w", err) + } + + // NOTE: even if it's a file that sops recognize as containing + // valid metadata, we want to ensure some metadata are present + // to report the file as encrypted. + if tree.Metadata.Version == "" { + return false, nil + } + if tree.Metadata.MessageAuthenticationCode == "" { + return false, nil + } + + return true, nil +} diff --git a/cmd/sops/subcommand/filestatus/filestatus_internal_test.go b/cmd/sops/subcommand/filestatus/filestatus_internal_test.go new file mode 100644 index 000000000..c058a506d --- /dev/null +++ b/cmd/sops/subcommand/filestatus/filestatus_internal_test.go @@ -0,0 +1,52 @@ +package filestatus + +import ( + "path" + "testing" + + "github.com/getsops/sops/v3/cmd/sops/common" + "github.com/getsops/sops/v3/config" + "github.com/stretchr/testify/require" +) + +const repoRoot = "../../../../" + +func fromRepoRoot(p string) string { + return path.Join(repoRoot, p) +} + +func TestFileStatus(t *testing.T) { + tests := []struct { + name string + file string + expectedEncrypted bool + }{ + { + name: "encrypted file should be reported as such", + file: "example.yaml", + expectedEncrypted: true, + }, + { + name: "plain text file should be reported as cleartext", + file: "functional-tests/res/plainfile.yaml", + }, + { + name: "file without mac should be reported as cleartext", + file: "functional-tests/res/plainfile.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := fromRepoRoot(tt.file) + s := common.DefaultStoreForPath(config.NewStoresConfig(), f) + encrypted, err := cfs(s, f) + require.Nil(t, err, "should not error") + if tt.expectedEncrypted { + require.True(t, encrypted, "file should have been reported as encrypted") + } else { + require.False(t, encrypted, "file should have been reported as cleartext") + } + }) + } +} diff --git a/functional-tests/res/plainfile.yaml b/functional-tests/res/plainfile.yaml new file mode 100644 index 000000000..bb56b0551 --- /dev/null +++ b/functional-tests/res/plainfile.yaml @@ -0,0 +1 @@ +hello: world