Skip to content

Commit ebd153f

Browse files
authored
Implement sops publish command (#473)
* Implement `sops publish` command Publishes a file to a pre-configured destination (this lives in the sops config file). Additionally, support re-encryption rules that work just like the creation rules. Initial support for S3/GCS. This is a part of the sops-workspace v2.0 project Includes the addition of a new dependency: github.com/googleapis/gax-go/v2 * code review changes; support global --verbose flag * Switch to recreation_rule with full support Reencryption rule is now recreation rule and supports everything that a creation rule does. Now, when you load a config for a file, you load either the creation rule or the destination rule. I'm not sure about this style long term, but it allows for support to be added for the recreation rules without a bigger refactor of how the config file works. * split loadForFileFromBytes into two functions remove branching based on destination rule or not, create one for creation rules and one for destination rules * pretty diff for keygroup updates in sops publish
1 parent d61906a commit ebd153f

38 files changed

+1972
-145
lines changed

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,31 @@ By default ``sops`` just dumps all the output to the standard output. We can use
791791
Beware using both ``--in-place`` and ``--output`` flags will result in an error.
792792
793793
794+
Using the publish command
795+
~~~~~~~~~~~~~~~~~~~~~~~~~
796+
``sops publish $file`` publishes a file to a pre-configured destination (this lives in the sops
797+
config file). Additionally, support re-encryption rules that work just like the creation rules.
798+
799+
This command requires a ``.sops.yaml`` configuration file. Below is an example:
800+
801+
.. code:: yaml
802+
803+
destination_rules:
804+
- s3_bucket: "sops-secrets"
805+
path_regex: s3/*
806+
recreation_rule:
807+
pgp: F69E4901EDBAD2D1753F8C67A64535C4163FB307
808+
- gcs_bucket: "sops-secrets"
809+
path_regex: gcs/*
810+
recreation_rule:
811+
pgp: F69E4901EDBAD2D1753F8C67A64535C4163FB307
812+
813+
The above configuration will place all files under ``s3/*`` into the S3 bucket ``sops-secrets`` and
814+
will place all files under ``gcs/*`` into the GCS bucket ``sops-secrets``. As well, it will decrypt
815+
these files and re-encrypt them using the ``F69E4901EDBAD2D1753F8C67A64535C4163FB307`` pgp key.
816+
817+
You would deploy a file to S3 with a command like: ``sops publish s3/app.yaml``
818+
794819
Important information on types
795820
------------------------------
796821

cmd/sops/common/common.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/fatih/color"
1112
wordwrap "github.com/mitchellh/go-wordwrap"
1213
"go.mozilla.org/sops"
1314
"go.mozilla.org/sops/cmd/sops/codes"
15+
"go.mozilla.org/sops/keys"
1416
"go.mozilla.org/sops/keyservice"
1517
"go.mozilla.org/sops/kms"
1618
"go.mozilla.org/sops/stores/dotenv"
@@ -339,3 +341,65 @@ func RecoverDataKeyFromBuggyKMS(opts GenericDecryptOpts, tree *sops.Tree) []byte
339341

340342
return nil
341343
}
344+
345+
type Diff struct {
346+
Common []keys.MasterKey
347+
Added []keys.MasterKey
348+
Removed []keys.MasterKey
349+
}
350+
351+
func max(a, b int) int {
352+
if a > b {
353+
return a
354+
}
355+
return b
356+
}
357+
358+
func DiffKeyGroups(ours, theirs []sops.KeyGroup) []Diff {
359+
var diffs []Diff
360+
for i := 0; i < max(len(ours), len(theirs)); i++ {
361+
var diff Diff
362+
var ourGroup, theirGroup sops.KeyGroup
363+
if len(ours) > i {
364+
ourGroup = ours[i]
365+
}
366+
if len(theirs) > i {
367+
theirGroup = theirs[i]
368+
}
369+
ourKeys := make(map[string]struct{})
370+
theirKeys := make(map[string]struct{})
371+
for _, key := range ourGroup {
372+
ourKeys[key.ToString()] = struct{}{}
373+
}
374+
for _, key := range theirGroup {
375+
if _, ok := ourKeys[key.ToString()]; ok {
376+
diff.Common = append(diff.Common, key)
377+
} else {
378+
diff.Added = append(diff.Added, key)
379+
}
380+
theirKeys[key.ToString()] = struct{}{}
381+
}
382+
for _, key := range ourGroup {
383+
if _, ok := theirKeys[key.ToString()]; !ok {
384+
diff.Removed = append(diff.Removed, key)
385+
}
386+
}
387+
diffs = append(diffs, diff)
388+
}
389+
return diffs
390+
}
391+
392+
func PrettyPrintDiffs(diffs []Diff) {
393+
for i, diff := range diffs {
394+
color.New(color.Underline).Printf("Group %d\n", i+1)
395+
for _, c := range diff.Common {
396+
fmt.Printf(" %s\n", c.ToString())
397+
}
398+
for _, c := range diff.Added {
399+
color.New(color.FgGreen).Printf("+++ %s\n", c.ToString())
400+
}
401+
for _, c := range diff.Removed {
402+
color.New(color.FgRed).Printf("--- %s\n", c.ToString())
403+
}
404+
}
405+
}

cmd/sops/main.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"go.mozilla.org/sops/cmd/sops/common"
2222
"go.mozilla.org/sops/cmd/sops/subcommand/groups"
2323
keyservicecmd "go.mozilla.org/sops/cmd/sops/subcommand/keyservice"
24+
publishcmd "go.mozilla.org/sops/cmd/sops/subcommand/publish"
2425
"go.mozilla.org/sops/cmd/sops/subcommand/updatekeys"
2526
"go.mozilla.org/sops/config"
2627
"go.mozilla.org/sops/gcpkms"
@@ -105,6 +106,49 @@ func main() {
105106
For more information, see the README at github.com/mozilla/sops`
106107
app.EnableBashCompletion = true
107108
app.Commands = []cli.Command{
109+
{
110+
Name: "publish",
111+
Usage: "Publish sops file to a configured destination",
112+
ArgsUsage: `file`,
113+
Flags: append([]cli.Flag{
114+
cli.BoolFlag{
115+
Name: "yes, y",
116+
Usage: `pre-approve all changes and run non-interactively`,
117+
},
118+
cli.BoolFlag{
119+
Name: "verbose",
120+
Usage: "Enable verbose logging output",
121+
},
122+
}, keyserviceFlags...),
123+
Action: func(c *cli.Context) error {
124+
if c.Bool("verbose") || c.GlobalBool("verbose") {
125+
logging.SetLevel(logrus.DebugLevel)
126+
}
127+
configPath, err := config.FindConfigFile(".")
128+
if err != nil {
129+
return common.NewExitError(err, codes.ErrorGeneric)
130+
}
131+
if c.NArg() < 1 {
132+
return common.NewExitError("Error: no file specified", codes.NoFileSpecified)
133+
}
134+
fileName := c.Args()[0]
135+
inputStore := inputStore(c, fileName)
136+
err = publishcmd.Run(publishcmd.Opts{
137+
ConfigPath: configPath,
138+
InputPath: fileName,
139+
InputStore: inputStore,
140+
Cipher: aes.NewCipher(),
141+
KeyServices: keyservices(c),
142+
Interactive: !c.Bool("yes"),
143+
})
144+
if cliErr, ok := err.(*cli.ExitError); ok && cliErr != nil {
145+
return cliErr
146+
} else if err != nil {
147+
return common.NewExitError(err, codes.ErrorGeneric)
148+
}
149+
return nil
150+
},
151+
},
108152
{
109153
Name: "keyservice",
110154
Usage: "start a SOPS key service server",
@@ -129,7 +173,7 @@ func main() {
129173
},
130174
},
131175
Action: func(c *cli.Context) error {
132-
if c.Bool("verbose") {
176+
if c.Bool("verbose") || c.GlobalBool("verbose") {
133177
logging.SetLevel(logrus.DebugLevel)
134178
}
135179
err := keyservicecmd.Run(keyservicecmd.Opts{
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package publish
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/ioutil"
7+
"os"
8+
"path/filepath"
9+
10+
"go.mozilla.org/sops"
11+
"go.mozilla.org/sops/cmd/sops/codes"
12+
"go.mozilla.org/sops/cmd/sops/common"
13+
"go.mozilla.org/sops/config"
14+
"go.mozilla.org/sops/keyservice"
15+
"go.mozilla.org/sops/logging"
16+
"go.mozilla.org/sops/version"
17+
18+
"github.com/sirupsen/logrus"
19+
)
20+
21+
var log *logrus.Logger
22+
23+
func init() {
24+
log = logging.NewLogger("PUBLISH")
25+
}
26+
27+
type Opts struct {
28+
Interactive bool
29+
Cipher sops.Cipher
30+
ConfigPath string
31+
InputPath string
32+
KeyServices []keyservice.KeyServiceClient
33+
InputStore sops.Store
34+
}
35+
36+
func Run(opts Opts) error {
37+
var fileContents []byte
38+
path, err := filepath.Abs(opts.InputPath)
39+
if err != nil {
40+
return err
41+
}
42+
info, err := os.Stat(path)
43+
if err != nil {
44+
return err
45+
}
46+
if info.IsDir() {
47+
return fmt.Errorf("can't operate on a directory")
48+
}
49+
_, fileName := filepath.Split(path)
50+
51+
conf, err := config.LoadDestinationRuleForFile(opts.ConfigPath, opts.InputPath, make(map[string]*string))
52+
if err != nil {
53+
return err
54+
}
55+
if conf.Destination == nil {
56+
return errors.New("no destination configured for this file")
57+
}
58+
59+
// Check that this is a sops-encrypted file
60+
tree, err := common.LoadEncryptedFile(opts.InputStore, opts.InputPath)
61+
if err != nil {
62+
return err
63+
}
64+
65+
// Re-encrypt if settings exist to do so
66+
if len(conf.KeyGroups[0]) != 0 {
67+
log.Debug("Re-encrypting tree before publishing")
68+
_, err = common.DecryptTree(common.DecryptTreeOpts{
69+
Cipher: opts.Cipher,
70+
IgnoreMac: false,
71+
Tree: tree,
72+
KeyServices: opts.KeyServices,
73+
})
74+
if err != nil {
75+
return err
76+
}
77+
78+
diffs := common.DiffKeyGroups(tree.Metadata.KeyGroups, conf.KeyGroups)
79+
keysWillChange := false
80+
for _, diff := range diffs {
81+
if len(diff.Added) > 0 || len(diff.Removed) > 0 {
82+
keysWillChange = true
83+
}
84+
}
85+
if keysWillChange {
86+
fmt.Printf("The following changes will be made to the file's key groups:\n")
87+
common.PrettyPrintDiffs(diffs)
88+
}
89+
90+
tree.Metadata = sops.Metadata{
91+
KeyGroups: conf.KeyGroups,
92+
UnencryptedSuffix: conf.UnencryptedSuffix,
93+
EncryptedSuffix: conf.EncryptedSuffix,
94+
Version: version.Version,
95+
ShamirThreshold: conf.ShamirThreshold,
96+
}
97+
98+
dataKey, errs := tree.GenerateDataKeyWithKeyServices(opts.KeyServices)
99+
if len(errs) > 0 {
100+
err = fmt.Errorf("Could not generate data key: %s", errs)
101+
return err
102+
}
103+
104+
err = common.EncryptTree(common.EncryptTreeOpts{
105+
DataKey: dataKey,
106+
Tree: tree,
107+
Cipher: opts.Cipher,
108+
})
109+
if err != nil {
110+
return err
111+
}
112+
113+
fileContents, err = opts.InputStore.EmitEncryptedFile(*tree)
114+
if err != nil {
115+
return common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree)
116+
}
117+
} else {
118+
fileContents, err = ioutil.ReadFile(path)
119+
if err != nil {
120+
return fmt.Errorf("could not read file: %s", err)
121+
}
122+
}
123+
124+
if opts.Interactive {
125+
var response string
126+
for response != "y" && response != "n" {
127+
fmt.Printf("uploading %s to %s ? (y/n): ", path, conf.Destination.Path(fileName))
128+
_, err := fmt.Scanln(&response)
129+
if err != nil {
130+
return err
131+
}
132+
}
133+
if response == "n" {
134+
return errors.New("Publish canceled")
135+
}
136+
}
137+
138+
err = conf.Destination.Upload(fileContents, fileName)
139+
if err != nil {
140+
return err
141+
}
142+
143+
return nil
144+
}
145+
146+
func min(a, b int) int {
147+
if a < b {
148+
return a
149+
}
150+
return b
151+
}

0 commit comments

Comments
 (0)