From 0fcf383d1c11f5c7ffefa41a427a64700cfdce60 Mon Sep 17 00:00:00 2001 From: John Cooper Date: Tue, 11 Nov 2025 14:48:32 -0800 Subject: [PATCH] possible solution for #1071 --- README.rst | 42 ++++++ cmd/sops/main.go | 34 ++++- cmd/sops/subcommand/updatekeys/updatekeys.go | 150 +++++++++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d356682691..ef733a56b9 100644 --- a/README.rst +++ b/README.rst @@ -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 ****************** diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 62d11f1623..7ad47ea89a 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -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) @@ -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 @@ -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{ @@ -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 { diff --git a/cmd/sops/subcommand/updatekeys/updatekeys.go b/cmd/sops/subcommand/updatekeys/updatekeys.go index 9dec066a67..9caaec56e7 100644 --- a/cmd/sops/subcommand/updatekeys/updatekeys.go +++ b/cmd/sops/subcommand/updatekeys/updatekeys.go @@ -1,6 +1,7 @@ package updatekeys import ( + "bytes" "fmt" "log" "os" @@ -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 @@ -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 { @@ -77,6 +218,10 @@ 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 } @@ -84,6 +229,11 @@ func updateFile(opts Opts) error { 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" {