mesh/merge-gate head fe2040c65ea2 merged into trunk 53e51864969f — pass: builds baserow, gitea, grafana, home-assistant, icecast, influxdb, letta, mailu, n…
mesh/repo-check head fe2040c65ea2 merged into trunk 53e51864969f — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery-group group fix/342-a-secret-is-never-a-setting ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
A setting outranked the vault: baserow, grafana, letta and unifi merged a layer's
password over the ${secret:...} in their runtime config, and the gitea,
home-assistant, icecast, influxdb, mailu and nextcloud clients read the settings
file before the vault's file. A secret in a setting cannot be rotated and is read
back by the settings verb.
The four files now keep their password protected, so the controller refuses a
setting of it. The six clients ignore a secret in the settings file and say so
once, by key and never with the value; icecast reads its admin password from the
vault's file. checks/secrets refuses a mergeable file whose secret a layer could
replace; searxng's nested server.secret_key is listed as known until the
controller protects a nested key.
212 lines
6.2 KiB
Go
212 lines
6.2 KiB
Go
// Command secrets refuses, in every module's manifest, a mergeable file that a setting could fill with a
|
|
// password, a token or an API key in place of the vault's (novox/hq issue 342).
|
|
//
|
|
// A password is not a setting: it comes from the key vault, and it can be rotated there. A file that says
|
|
// `"merge": "json"` takes the keys of the mesh-wide and the node's layers over its own content, so a key the
|
|
// content fills with `${secret:<name>}` is one a layer could replace, and the vault's value would lose to it
|
|
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
|
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
|
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
|
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it.
|
|
//
|
|
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
|
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
|
// beside this check, one line per key, until the controller refuses a nested key; a line there that no longer
|
|
// names a finding is refused too, so the list only shrinks.
|
|
//
|
|
// go run . <catalogue root>
|
|
//
|
|
// Exits 1 on a finding.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Finding is one key of one mergeable file a layer could fill in place of the vault.
|
|
type Finding struct {
|
|
Module, ID, Key string
|
|
}
|
|
|
|
func (f Finding) String() string { return f.Module + " " + f.ID + " " + f.Key }
|
|
|
|
// Check reads every manifest under root/modules and answers each secret-bearing key a layer could replace.
|
|
func Check(root string) ([]Finding, error) {
|
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(manifests)
|
|
var out []Finding
|
|
for _, path := range manifests {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m struct {
|
|
Module string `json:"module"`
|
|
Resources []map[string]any `json:"resources"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
for _, r := range m.Resources {
|
|
if how, _ := r["merge"].(string); how == "" {
|
|
continue
|
|
}
|
|
id, _ := r["id"].(string)
|
|
content, _ := r["content"].(string)
|
|
var doc map[string]any
|
|
if strings.TrimSpace(content) == "" {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal([]byte(content), &doc); err != nil {
|
|
return nil, fmt.Errorf("%s: %s merges as JSON and its content is not JSON: %w", m.Module, id, err)
|
|
}
|
|
protected := set(r["protected"])
|
|
takes, names := r["takes-settings"]
|
|
taken := set(takes)
|
|
for _, key := range sortedKeys(doc) {
|
|
for _, at := range secretsBelow(key, doc[key]) {
|
|
switch {
|
|
case protected[key]:
|
|
case names && !taken[key]:
|
|
default:
|
|
out = append(out, Finding{m.Module, id, at})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// secretsBelow answers the dotted path of every string at or below value that names a vault secret.
|
|
func secretsBelow(at string, value any) []string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if strings.Contains(v, "${secret:") {
|
|
return []string{at}
|
|
}
|
|
case map[string]any:
|
|
var out []string
|
|
for _, k := range sortedKeys(v) {
|
|
out = append(out, secretsBelow(at+"."+k, v[k])...)
|
|
}
|
|
return out
|
|
case []any:
|
|
var out []string
|
|
for i, e := range v {
|
|
out = append(out, secretsBelow(fmt.Sprintf("%s[%d]", at, i), e)...)
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func set(v any) map[string]bool {
|
|
out := map[string]bool{}
|
|
list, _ := v.([]any)
|
|
for _, e := range list {
|
|
if s, ok := e.(string); ok {
|
|
out[s] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortedKeys(m map[string]any) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// Known reads the lines of the known list: "<module> <resource> <key>", # comments and blank lines skipped.
|
|
func Known(path string) (map[string]bool, error) {
|
|
f, err := os.Open(path)
|
|
if os.IsNotExist(err) {
|
|
return map[string]bool{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
out := map[string]bool{}
|
|
s := bufio.NewScanner(f)
|
|
for s.Scan() {
|
|
line := strings.TrimSpace(s.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
out[strings.Join(strings.Fields(line), " ")] = true
|
|
}
|
|
return out, s.Err()
|
|
}
|
|
|
|
// Judge splits the findings into those refused and the known lines that no longer name one.
|
|
func Judge(found []Finding, known map[string]bool) (refused []Finding, stale []string) {
|
|
seen := map[string]bool{}
|
|
for _, f := range found {
|
|
seen[f.String()] = true
|
|
if !known[f.String()] {
|
|
refused = append(refused, f)
|
|
}
|
|
}
|
|
for line := range known {
|
|
if !seen[line] {
|
|
stale = append(stale, line)
|
|
}
|
|
}
|
|
sort.Strings(stale)
|
|
return refused, stale
|
|
}
|
|
|
|
// remedy says how a finding is put right.
|
|
func remedy(key string) string {
|
|
top, _, nested := strings.Cut(key, ".")
|
|
if !nested {
|
|
return fmt.Sprintf("list %q in the file's \"protected\", so the controller refuses a setting of it", top)
|
|
}
|
|
return fmt.Sprintf("the controller protects a top-level key only; list %q in the file's \"protected\" if "+
|
|
"nothing else below it is the operator's, or leave it out of the file's \"takes-settings\"", top)
|
|
}
|
|
|
|
func main() {
|
|
root := "."
|
|
if len(os.Args) > 1 {
|
|
root = os.Args[1]
|
|
}
|
|
found, err := Check(root)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
known, err := Known("known")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
refused, stale := Judge(found, known)
|
|
for _, f := range refused {
|
|
fmt.Printf("%s: %s fills %s with a vault secret, and a setting could replace it (novox/hq issue 342): %s\n",
|
|
f.Module, f.ID, f.Key, remedy(f.Key))
|
|
}
|
|
for _, line := range stale {
|
|
fmt.Printf("checks/secrets/known names %q, which no longer holds a secret a setting could replace: "+
|
|
"remove the line\n", line)
|
|
}
|
|
if len(refused) > 0 || len(stale) > 0 {
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("secrets: no setting can replace a vault secret in a mergeable file (%d known, nested)\n", len(known))
|
|
}
|