Merge pull request 'A file's secrets are sealed values entry by entry, masked and named per secret (issue 1126; group fix/1126-seed-same-3)' (#115) from fix/1126-seed-same-3 into main
This commit was merged in pull request #115.
This commit is contained in:
@@ -86,8 +86,25 @@ func walkDiffering(a, b any, at string, out *[]string) {
|
|||||||
case !inA || !inB:
|
case !inA || !inB:
|
||||||
*out = append(*out, join(at, k))
|
*out = append(*out, join(at, k))
|
||||||
case secretKey(k):
|
case secretKey(k):
|
||||||
if !sameAny(x, y) {
|
// A file's secrets (SecretsField) are named entry by entry, each entry never entered; any other
|
||||||
*out = append(*out, join(at, k))
|
// object under a secret key, a vault box included, is one path.
|
||||||
|
sx, isSX := x.(map[string]any)
|
||||||
|
sy, isSY := y.(map[string]any)
|
||||||
|
if k != SecretsField || !isSX || !isSY {
|
||||||
|
if !sameAny(x, y) {
|
||||||
|
*out = append(*out, join(at, k))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for name := range sx {
|
||||||
|
if v, in := sy[name]; !in || !sameAny(sx[name], v) {
|
||||||
|
*out = append(*out, join(join(at, k), name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range sy {
|
||||||
|
if _, in := sx[name]; !in {
|
||||||
|
*out = append(*out, join(join(at, k), name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
walkDiffering(x, y, join(at, k), out)
|
walkDiffering(x, y, join(at, k), out)
|
||||||
|
|||||||
@@ -265,12 +265,12 @@ func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) {
|
|||||||
va, inA := ma[k]
|
va, inA := ma[k]
|
||||||
vb, inB := mb[k]
|
vb, inB := mb[k]
|
||||||
if secretKey(k) {
|
if secretKey(k) {
|
||||||
word := maskWord(va, vb, inA && inB, sameValue)
|
x, y := maskAll(k, va, vb, inA && inB, sameValue)
|
||||||
if inA {
|
if inA {
|
||||||
outA[k] = word
|
outA[k] = x
|
||||||
}
|
}
|
||||||
if inB {
|
if inB {
|
||||||
outB[k] = word
|
outB[k] = y
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -328,6 +328,36 @@ func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) {
|
|||||||
return a, b
|
return a, b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskAll is a value under a key a secret could be under, masked as one word (maskWord); a file's secrets
|
||||||
|
// (SecretsField) alone entry by entry, each its own sealed value: "another seal of the same value" or "another value"
|
||||||
|
// per secret, its name shown (issue 1126, C1). Any other object, a vault box included, is one word, judged whole.
|
||||||
|
func maskAll(key string, a, b any, both bool, sameValue func(a, b string) (bool, bool)) (any, any) {
|
||||||
|
ma, isA := a.(map[string]any)
|
||||||
|
mb, isB := b.(map[string]any)
|
||||||
|
if key != SecretsField || (!isA && !isB) {
|
||||||
|
w := maskWord(a, b, both, sameValue)
|
||||||
|
return w, w
|
||||||
|
}
|
||||||
|
outA, outB := map[string]any{}, map[string]any{}
|
||||||
|
for k, va := range ma {
|
||||||
|
vb, inB := mb[k]
|
||||||
|
outA[k] = maskWord(va, vb, isB && inB, sameValue)
|
||||||
|
}
|
||||||
|
for k, vb := range mb {
|
||||||
|
va, inA := ma[k]
|
||||||
|
outB[k] = maskWord(va, vb, isA && inA, sameValue)
|
||||||
|
}
|
||||||
|
// A map on one side and another value on the other: the other is one word.
|
||||||
|
var ra, rb any = outA, outB
|
||||||
|
if !isA {
|
||||||
|
ra = maskWord(a, b, both, sameValue)
|
||||||
|
}
|
||||||
|
if !isB {
|
||||||
|
rb = maskWord(a, b, both, sameValue)
|
||||||
|
}
|
||||||
|
return ra, rb
|
||||||
|
}
|
||||||
|
|
||||||
func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string {
|
func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string {
|
||||||
if !both {
|
if !both {
|
||||||
return "<masked>"
|
return "<masked>"
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed`
|
// Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed`
|
||||||
// and a vault box (catalogue.VaultSealedField), as their JSON text (a box is carried as its JSON, a sealed value as
|
// a vault box (catalogue.VaultSealedField) and each entry of a file's secrets (SecretsField), as their JSON text (a box is carried as its JSON, a sealed value as
|
||||||
// the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only
|
// the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only
|
||||||
// at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its
|
// at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its
|
||||||
// consumer's secret on one node hold one value).
|
// consumer's secret on one node hold one value).
|
||||||
@@ -22,6 +23,44 @@ var SealedFields = []string{"sealed", catalogue.VaultSealedField}
|
|||||||
// The module is the composition's: a slot is never compared across compositions.
|
// The module is the composition's: a slot is never compared across compositions.
|
||||||
func SealSlot(resource, field string) string { return resource + "#" + field }
|
func SealSlot(resource, field string) string { return resource + "#" + field }
|
||||||
|
|
||||||
|
// SecretsField is the field of a file that carries, by name, each sealed value its content asks for
|
||||||
|
// (${secret:<name>}, catalogue's intoFile): each entry is a slot of its own (SecretSlotField), so a secret sealed again
|
||||||
|
// is judged by the value it opens to, entry by entry, like a file's `sealed` (issue 1126, C1; the seed-7 probe: about 45
|
||||||
|
// files on ace and novox differed by their secrets' bytes alone).
|
||||||
|
const SecretsField = "secrets"
|
||||||
|
|
||||||
|
// SecretSlotField is the field part of the slot of one entry of a file's secrets (SealSlot(id, SecretSlotField(name))).
|
||||||
|
func SecretSlotField(name string) string { return SecretsField + "." + name }
|
||||||
|
|
||||||
|
// sealAt is one sealed value of a resource: its slot's field, its place (a top-level field, or an entry of the
|
||||||
|
// secrets), and its raw JSON.
|
||||||
|
type sealAt struct {
|
||||||
|
field, entry string
|
||||||
|
raw json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealsAt is every sealed value of one resource's fields, in a fixed order: SealedFields, then the secrets by name.
|
||||||
|
func sealsAt(fields map[string]json.RawMessage) []sealAt {
|
||||||
|
var out []sealAt
|
||||||
|
for _, field := range SealedFields {
|
||||||
|
if f, has := fields[field]; has {
|
||||||
|
out = append(out, sealAt{field: field, raw: f})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var secrets map[string]json.RawMessage
|
||||||
|
if f, has := fields[SecretsField]; has && json.Unmarshal(f, &secrets) == nil {
|
||||||
|
names := make([]string, 0, len(secrets))
|
||||||
|
for name := range secrets {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
out = append(out, sealAt{field: SecretSlotField(name), entry: name, raw: secrets[name]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// SealsIn is every sealed value c carries, by slot; nil when it carries none.
|
// SealsIn is every sealed value c carries, by slot; nil when it carries none.
|
||||||
func SealsIn(c Composed) Seals {
|
func SealsIn(c Composed) Seals {
|
||||||
var out Seals
|
var out Seals
|
||||||
@@ -30,16 +69,12 @@ func SealsIn(c Composed) Seals {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, field := range SealedFields {
|
for _, s := range sealsAt(fields) {
|
||||||
f, has := fields[field]
|
if text := sealedText(s.raw); text != "" {
|
||||||
if !has {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if text := sealedText(f); text != "" {
|
|
||||||
if out == nil {
|
if out == nil {
|
||||||
out = Seals{}
|
out = Seals{}
|
||||||
}
|
}
|
||||||
out[SealSlot(id, field)] = text
|
out[SealSlot(id, s.field)] = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,25 +94,34 @@ func KeepSeals(c Composed, kept Seals, same func(a, b string) (same, known bool)
|
|||||||
for i, raw := range c.Resources {
|
for i, raw := range c.Resources {
|
||||||
id, fields, ok := sealedFieldsOf(raw, i)
|
id, fields, ok := sealedFieldsOf(raw, i)
|
||||||
if ok {
|
if ok {
|
||||||
for _, field := range SealedFields {
|
for _, s := range sealsAt(fields) {
|
||||||
f, has := fields[field]
|
ours := sealedText(s.raw)
|
||||||
if !has {
|
theirs, held := kept[SealSlot(id, s.field)]
|
||||||
continue
|
|
||||||
}
|
|
||||||
ours := sealedText(f)
|
|
||||||
theirs, held := kept[SealSlot(id, field)]
|
|
||||||
if !held || ours == "" || theirs == ours {
|
if !held || ours == "" || theirs == ours {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s, known := same(ours, theirs); !known || !s {
|
if opens, known := same(ours, theirs); !known || !opens {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value := json.RawMessage(theirs)
|
value := json.RawMessage(theirs)
|
||||||
if f[0] == '"' {
|
if s.raw[0] == '"' {
|
||||||
value, _ = json.Marshal(theirs)
|
value, _ = json.Marshal(theirs)
|
||||||
}
|
}
|
||||||
if swapped, ok := withField(raw, field, value); ok {
|
if s.entry == "" {
|
||||||
raw = swapped
|
if swapped, ok := withField(raw, s.field, value); ok {
|
||||||
|
raw = swapped
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// An entry of the secrets: swapped inside the secrets as they stand in raw, then the secrets in raw.
|
||||||
|
var now map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &now) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if secrets, ok := withField(now[SecretsField], s.entry, value); ok {
|
||||||
|
if swapped, ok := withField(raw, SecretsField, secrets); ok {
|
||||||
|
raw = swapped
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -327,6 +328,60 @@ func TestPlacement(t *testing.T) {
|
|||||||
t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths)
|
t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Issue 1126, C1, the seed-7 probe: a file naming secrets carries each sealed value in its `secrets`, and each entry
|
||||||
|
// is a slot of its own. The shape is intoFile's output, captured from catalogue for a file naming two secrets
|
||||||
|
// (2026-10-28), with the module a composition names each resource of; only the boxes are replaced.
|
||||||
|
withSecrets := func(db, key string) json.RawMessage {
|
||||||
|
return json.RawMessage(`{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` +
|
||||||
|
`"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db +
|
||||||
|
`","secret-key":"` + key + `"},"type":"file"}`)
|
||||||
|
}
|
||||||
|
if c := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-1"))},
|
||||||
|
[]Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 {
|
||||||
|
t.Errorf("a secret sealed again at its own entry is not the same: %+v", c)
|
||||||
|
}
|
||||||
|
if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) {
|
||||||
|
t.Errorf("the box that runs is not kept byte for byte: %s", kept.Resources[0])
|
||||||
|
}
|
||||||
|
if s := SealsIn(comp(withSecrets("box-2", "box-1"))); s[SealSlot("grafana.ini", SecretSlotField("postgres-database"))] != "box-2" || len(s) != 2 {
|
||||||
|
t.Errorf("the secrets' slots are %v", s)
|
||||||
|
}
|
||||||
|
// Another value at one entry differs there alone, each entry masked by what it opens to.
|
||||||
|
// box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values.
|
||||||
|
mixed := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-5"))},
|
||||||
|
[]Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) {
|
||||||
|
return (a == "box-2" && b == "box-4") || a == b, true
|
||||||
|
})
|
||||||
|
if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 {
|
||||||
|
t.Fatalf("a secret of another value is not shown: %+v", mixed)
|
||||||
|
}
|
||||||
|
if body := mixed.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"secrets.secret-key"}) ||
|
||||||
|
!strings.Contains(body.Composed, `"secret-key":"<masked: another value>"`) || strings.Contains(body.Composed+body.Running, "box-") ||
|
||||||
|
!strings.Contains(body.Running, `"postgres-database":"<masked: the same on both sides>"`) {
|
||||||
|
t.Errorf("a file's secrets shown as %+v", body)
|
||||||
|
}
|
||||||
|
if c, _ := MaskedPair(withSecrets("box-2", "box-5"), withSecrets("box-4", "box-6"), func(a, b string) (bool, bool) {
|
||||||
|
return a == "box-2", true
|
||||||
|
}); !strings.Contains(c, `"postgres-database":"<masked: another seal of the same value>"`) || !strings.Contains(c, `"secret-key":"<masked: another value>"`) {
|
||||||
|
t.Errorf("each secret is not masked by what it opens to: %s", c)
|
||||||
|
}
|
||||||
|
// Only a file's secrets are split by entry: a vault box (an object under `vault-sealed`) is one masked word and one
|
||||||
|
// path, judged whole by what it opens to (rev-seed-same-3, round 1).
|
||||||
|
box := func(ct string) json.RawMessage {
|
||||||
|
return json.RawMessage(`{"id":"core-box","type":"file","module":"nats","path":"/etc/core","mode":"0600","vault-sealed":{"v":1,"eph":"e-` +
|
||||||
|
ct + `","ct":"` + ct + `"}}`)
|
||||||
|
}
|
||||||
|
vault := CompareComposedWith(map[string]Composed{"k": comp(box("c1"))}, []Composed{comp(box("c2"))},
|
||||||
|
func(a, b string) (bool, bool) {
|
||||||
|
return false, strings.Contains(a, `"ct":"c1"`) && strings.Contains(b, `"ct":"c2"`)
|
||||||
|
})
|
||||||
|
if len(vault.Differs) != 1 || len(vault.Differs[0].Bodies) != 1 {
|
||||||
|
t.Fatalf("a box of another value is not shown: %+v", vault)
|
||||||
|
}
|
||||||
|
if body := vault.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"vault-sealed"}) ||
|
||||||
|
!strings.Contains(body.Composed, `"vault-sealed":"<masked: another value>"`) || strings.Contains(body.Composed+body.Running, "c1") {
|
||||||
|
t.Errorf("a vault box shown as %+v", body)
|
||||||
|
}
|
||||||
// Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a
|
// Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a
|
||||||
// key a secret could be under is named and never shown.
|
// key a secret could be under is named and never shown.
|
||||||
long := strings.Repeat("x", MaxBody)
|
long := strings.Repeat("x", MaxBody)
|
||||||
|
|||||||
Reference in New Issue
Block a user