Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,48 @@ separated list.
SOPS will prompt you with the changes to be made. This interactivity can be
disabled by supplying the ``-y`` flag.

Global update
=============

You can apply key updates to all managed files with ``--global``:

.. code:: sh

$ sops updatekeys --global
$ sops updatekeys --global -y # non‑interactive
$ sops updatekeys --global --dry-run # show what would change

Behavior:

* Scan starting at the directory containing ``.sops.yaml`` (or the current working directory if ``--config`` not set).
* A file is considered for update only if:
- It contains SOPS metadata (``sops`` section) and
- A creation rule in ``.sops.yaml`` matches its path.
* Files missing metadata or a matching creation rule are silently ignored (reported as ignored, not errors).
* In normal mode, eligible files whose key groups (or Shamir threshold, if configured) differ from the matching creation rule are updated in place.
* In ``--dry-run`` mode, no files are modified; a concise list of files that would be changed is printed.

Examples:

.. code:: sh

# See which files would be updated
$ sops updatekeys --global --dry-run
Files that would be updated:
secrets/app1.yaml
prod/creds.enc.json

# Perform the update
$ sops updatekeys --global -y

If there are no changes needed, files are skipped. Errors reading individual files are aggregated and reported at the end.

Flags:

* ``--global``: enable global scan/update
* ``--dry-run``: with ``--global``, list pending changes only
* ``-y`` / ``--yes``: auto-approve per‑file changes

``rotate`` command
******************

Expand Down
34 changes: 33 additions & 1 deletion cmd/sops/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ func main() {
ArgsUsage: `[index]`,

Action: func(c *cli.Context) error {
if c.NArg() != 1 {
if c.NArg() != 1 {
return common.NewExitError(fmt.Errorf("error: exactly one positional argument (index) required"), codes.ErrorGeneric)
}
group, err := strconv.ParseUint(c.Args().First(), 10, 32)
Expand Down Expand Up @@ -722,6 +722,14 @@ func main() {
Name: "input-type",
Usage: "currently ini, json, yaml, dotenv and binary are supported. If not set, sops will use the file's extension to determine the type",
},
cli.BoolFlag{
Name: "global, g",
Usage: `attempts to discover all files currently managed with SOPS, and updates their encryption keys`,
},
cli.BoolFlag{
Name: "dry-run",
Usage: `show what files would be updated in global mode, but don't actually perform any updates`,
},
}, keyserviceFlags...),
Action: func(c *cli.Context) error {
var err error
Expand All @@ -734,9 +742,31 @@ func main() {
return common.NewExitError(err, codes.ErrorGeneric)
}
}

// Global mode: no file argument required
if c.Bool("global") {
err = updatekeys.UpdateKeys(updatekeys.Opts{
InputPath: "", // ignored in global mode
ShamirThreshold: c.Int("shamir-secret-sharing-threshold"),
KeyServices: keyservices(c),
Interactive: !c.Bool("yes"),
ConfigPath: configPath,
InputType: c.String("input-type"),
Global: true,
DryRun: c.Bool("dry-run"),
})
if cliErr, ok := err.(*cli.ExitError); ok && cliErr != nil {
return cliErr
} else if err != nil {
return common.NewExitError(err, codes.ErrorGeneric)
}
return nil
}

if c.NArg() < 1 {
return common.NewExitError("Error: no file specified", codes.NoFileSpecified)
}

failedCounter := 0
for _, path := range c.Args() {
err := updatekeys.UpdateKeys(updatekeys.Opts{
Expand All @@ -746,6 +776,8 @@ func main() {
Interactive: !c.Bool("yes"),
ConfigPath: configPath,
InputType: c.String("input-type"),
Global: c.Bool("global"),
DryRun: c.Bool("dry-run"),
})

if c.NArg() == 1 {
Expand Down
150 changes: 150 additions & 0 deletions cmd/sops/subcommand/updatekeys/updatekeys.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package updatekeys

import (
"bytes"
"fmt"
"log"
"os"
Expand All @@ -21,10 +22,15 @@ type Opts struct {
Interactive bool
ConfigPath string
InputType string
Global bool // apply updatekey to all managed files
DryRun bool // do not modify files in global mode, only show intended changes
}

// UpdateKeys update the keys for a given file
func UpdateKeys(opts Opts) error {
if opts.Global {
return updateAll(opts)
}
path, err := filepath.Abs(opts.InputPath)
if err != nil {
return err
Expand All @@ -40,6 +46,141 @@ func UpdateKeys(opts Opts) error {
return updateFile(opts)
}

func updateAll(opts Opts) error {
// Root scoped to config file directory or current working directory
root := "."
if opts.ConfigPath != "" {
root = filepath.Dir(opts.ConfigPath)
}
absRoot, err := filepath.Abs(root)
if err != nil {
return err
}

log.Printf("Global updatekeys: scanning %s", absRoot)

var updated, skipped int
var errs []error
var filesToUpdate []string

err = filepath.Walk(absRoot, func(p string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
errs = append(errs, walkErr)
return nil
}
if info.IsDir() {
// skip common large/irrelevant dirs
base := filepath.Base(p)
if base == ".git" || base == "vendor" || base == ".idea" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}

// Skip the config file itself
if filepath.Base(p) == ".sops.yaml" || filepath.Base(p) == ".sops.yml" {
skipped++
return nil
}

// Determine if this file is a SOPS-managed file (contains SOPS metadata); if not, skip.
data, rerr := os.ReadFile(p)
if rerr != nil {
errs = append(errs, fmt.Errorf("read failed for %s: %w", p, rerr))
return nil
}

// Heuristic: look for common SOPS metadata markers, this could be better?
hasMeta := bytes.Contains(data, []byte("sops:")) || bytes.Contains(data, []byte(`"sops"`))
if !hasMeta {
skipped++
return nil
}

// Determine if this file has a creation rule; if not, skip
conf, cerr := config.LoadCreationRuleForFile(opts.ConfigPath, p, make(map[string]*string))
if cerr != nil || conf == nil {
log.Printf("Ignoring file %s: no matching creation rule", p)
skipped++
return nil
}
fileOpts := opts
fileOpts.InputPath = p
if opts.DryRun {
would, werr := wouldUpdate(fileOpts)
if werr != nil {
errs = append(errs, fmt.Errorf("check failed for %s: %w", p, werr))
return nil
}
if would {
filesToUpdate = append(filesToUpdate, p)
}
} else {
if uErr := updateFile(fileOpts); uErr != nil {
errs = append(errs, fmt.Errorf("update failed for %s: %w", p, uErr))
} else {
updated++
}
}
return nil
})
if err != nil {
errs = append(errs, err)
}

if opts.DryRun {
log.Printf("Global dry-run updatekeys complete: would update %d files, skipped %d, errors %d", len(filesToUpdate), skipped, len(errs))
if len(filesToUpdate) > 0 {
fmt.Printf("Files that would be updated:\n")
for _, f := range filesToUpdate {
fmt.Printf(" %s\n", f)
}
}
} else {
log.Printf("Global updatekeys complete: updated=%d skipped=%d errors=%d", updated, skipped, len(errs))
}
if len(errs) > 0 {
return fmt.Errorf("global updatekeys finished with errors: first=%v (total %d)", errs[0], len(errs))
}
return nil
}

func wouldUpdate(opts Opts) (bool, error) {
sc, err := config.LoadStoresConfig(opts.ConfigPath)
if err != nil {
return false, err
}
store := common.DefaultStoreForPathOrFormat(sc, opts.InputPath, opts.InputType)
tree, err := common.LoadEncryptedFile(store, opts.InputPath)
if err != nil {
return false, err
}
conf, err := config.LoadCreationRuleForFile(opts.ConfigPath, opts.InputPath, make(map[string]*string))
if err != nil {
return false, err
}
if conf == nil {
return false, fmt.Errorf("The config file %s does not contain any creation rule", opts.ConfigPath)
}

diffs := common.DiffKeyGroups(tree.Metadata.KeyGroups, conf.KeyGroups)
keysWillChange := false
for _, diff := range diffs {
if len(diff.Added) > 0 || len(diff.Removed) > 0 {
keysWillChange = true
}
}

var shamirThreshold = tree.Metadata.ShamirThreshold
if opts.ShamirThreshold != 0 {
shamirThreshold = opts.ShamirThreshold
}
shamirThreshold = min(shamirThreshold, len(conf.KeyGroups))
shamirThresholdWillChange := tree.Metadata.ShamirThreshold != shamirThreshold

return keysWillChange || shamirThresholdWillChange, nil
}

func updateFile(opts Opts) error {
sc, err := config.LoadStoresConfig(opts.ConfigPath)
if err != nil {
Expand Down Expand Up @@ -77,13 +218,22 @@ func updateFile(opts Opts) error {
var shamirThresholdWillChange = tree.Metadata.ShamirThreshold != shamirThreshold

if !keysWillChange && !shamirThresholdWillChange {
if opts.DryRun {
log.Printf("[dry-run] File %s already up to date", opts.InputPath)
return nil
}
log.Printf("File %s already up to date", opts.InputPath)
return nil
}
fmt.Printf("The following changes will be made to the file's groups:\n")
common.PrettyPrintShamirDiff(tree.Metadata.ShamirThreshold, shamirThreshold)
common.PrettyPrintDiffs(diffs)

if opts.DryRun {
log.Printf("[dry-run] Would update file %s (no changes written)", opts.InputPath)
return nil
}

if opts.Interactive {
var response string
for response != "y" && response != "n" {
Expand Down
Loading