Merge pull request 'settings: no layer replaces a value the vault fills, at any depth (issue 610)' (#308) from fix/610-no-layer-replaces-a-vault-secret into main
This commit was merged in pull request #308.
This commit is contained in:
@@ -192,6 +192,16 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what
|
|||||||
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
|
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
|
||||||
layer.From, key, what, key)
|
layer.From, key, what, key)
|
||||||
}
|
}
|
||||||
|
// **No layer replaces a secret the vault fills, at any depth** (novox/hq issue 610): a
|
||||||
|
// mergeable file's `${secret:<name>}` is the vault's value, and a layer that reached it —
|
||||||
|
// `server.secret_key` under a `server` the file takes — would put a value the operator or an
|
||||||
|
// agent chose where the vault's belongs. Judged against the file's own content, so it holds
|
||||||
|
// whatever a module's `protected` list says.
|
||||||
|
if at := secretReplacedBy(base[key], value, key); at != "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s sets %q on %v, which the vault fills (${secret:…}); a secret comes from the vault, "+
|
||||||
|
"never from a setting (novox/hq issues 342, 610)", layer.From, at, what)
|
||||||
|
}
|
||||||
merged[key] = mergeValue(merged[key], value)
|
merged[key] = mergeValue(merged[key], value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,3 +586,54 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secretReplacedBy is the path in a mergeable file's own content that a layer's value would replace while the
|
||||||
|
// vault fills it — a string holding `${secret:` — or "" when the value leaves every such string as it is
|
||||||
|
// (novox/hq issue 610). Maps merge key by key (mergeValue), so only the keys the value names are judged; any
|
||||||
|
// other value replaces the whole of what it lands on.
|
||||||
|
func secretReplacedBy(base, value any, at string) string {
|
||||||
|
if baseMap, isMap := base.(map[string]any); isMap {
|
||||||
|
if valueMap, also := value.(map[string]any); also {
|
||||||
|
keys := make([]string, 0, len(valueMap))
|
||||||
|
for k := range valueMap {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
if found := secretReplacedBy(baseMap[k], valueMap[k], at+"."+k); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return secretIn(base, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretIn is the first path under v holding a vault placeholder, or "".
|
||||||
|
func secretIn(v any, at string) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.Contains(t, "${secret:") {
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(t))
|
||||||
|
for k := range t {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
if found := secretIn(t[k], at+"."+k); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for i, item := range t {
|
||||||
|
if found := secretIn(item, fmt.Sprintf("%s[%d]", at, i)); found != "" {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,3 +234,29 @@ func TestAServedValueMayBeTheOperators(t *testing.T) {
|
|||||||
t.Errorf("a setting a served fact asks for is not stray: %v", unused)
|
t.Errorf("a setting a served fact asks for is not stray: %v", unused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No layer replaces a value the vault fills, at any depth (novox/hq issue 610): searxng's server.secret_key
|
||||||
|
// sits under `server`, a key its file takes, and a layer reaching it was let through by a top-level-only check.
|
||||||
|
func TestNoLayerReplacesAVaultSecretAtAnyDepth(t *testing.T) {
|
||||||
|
file := map[string]any{"id": "settings", "type": "file", "merge": MergeJSON,
|
||||||
|
"content": `{"server": {"secret_key": "${secret:secret}", "port": 8080}, "ui": {"theme": "simple"}, "token": "${secret:token}"}`}
|
||||||
|
layer := func(values map[string]any) []Layer { return []Layer{{From: "anchor", Values: values}} }
|
||||||
|
|
||||||
|
merged, err := ApplySettings(file, layer(map[string]any{"server": map[string]any{"port": float64(9000)}, "ui": map[string]any{"theme": "dark"}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a key beside the secret was refused: %v", err)
|
||||||
|
}
|
||||||
|
if c := merged["content"].(string); !strings.Contains(c, `"secret_key": "${secret:secret}"`) || !strings.Contains(c, `"port": 9000`) {
|
||||||
|
t.Fatalf("the secret or the setting did not land as meant:\n%s", c)
|
||||||
|
}
|
||||||
|
for name, values := range map[string]map[string]any{
|
||||||
|
"the nested secret": {"server": map[string]any{"secret_key": "chosen"}},
|
||||||
|
"the whole map holding it": {"server": "elsewhere"},
|
||||||
|
"a top-level secret": {"token": "chosen"},
|
||||||
|
"the secret, emptied": {"server": map[string]any{"secret_key": ""}},
|
||||||
|
} {
|
||||||
|
if _, err := ApplySettings(file, layer(values)); err == nil || !strings.Contains(err.Error(), "vault") {
|
||||||
|
t.Errorf("%s: %v, want a refusal naming the vault", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user