diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 7f06093..049112a 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -397,6 +397,8 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro if err := RefuseExistingServers(ctx, d.Run, creds); err != nil { return result, failed(StepBundle, err) } + // From here on nothing this installer says contains the values it just made. + say = Masking(say, creds) root, err := RewriteRoot(&rewritten, creds) if err != nil { return result, failed(StepBundle, err) diff --git a/internal/bootstrap/rootsecrets.go b/internal/bootstrap/rootsecrets.go index 245cf20..06a81f2 100644 --- a/internal/bootstrap/rootsecrets.go +++ b/internal/bootstrap/rootsecrets.go @@ -143,6 +143,20 @@ func freshSecret() (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } +// Masking makes a reporter that never says the credentials this run made. +// +// The applier reports each action with its command line, and two of them now carry a real +// password — the context schemas' connection strings and the broker's change_password. Those +// lines go to a terminal and to whatever keeps the transcript, which for the lab is a file. The +// exact values are known here, so they are replaced wherever they appear, in every line said. +func Masking(say func(string), c RootCredentials) func(string) { + replacer := strings.NewReplacer(c.Store, "…", c.Broker, "…") + if c.Store == "" || c.Broker == "" { + return say + } + return func(line string) { say(replacer.Replace(line)) } +} + // RefuseExistingServers stops a run that would put a made credential in front of a server raised // by an earlier installer with the template's. // @@ -219,14 +233,16 @@ func RewriteRoot(r *Rewritten, c RootCredentials) (RootRewrite, error) { // Verified by a marker on the broker's own data volume holding this password's fingerprint — // the image carries nothing that can try a password from inside, and a marker that merely // existed would let a regenerated password go unapplied for ever. What proves the password - // works is the control plane answering over it, a few resources later. + // works is the control plane answering over it, a few resources later. The marker ends in a + // newline because the verify reads it with the shell's `read`, which fails at end of file + // without one — an action that ran and a verify that said no, once, in the lab. fp := credentialFingerprint(c.Broker) action := templateBrokerReady + "\n" + " {\n" + " \"id\": \"broker-admin\",\n" + " \"type\": \"action\",\n" + " \"in\": \"mesh-broker\",\n" + - " \"command\": [\"sh\", \"-c\", \"lavinmqctl change_password " + BrokerAdminUser + " '" + c.Broker + "' && printf %s " + fp + " > " + brokerAdminMarker + "\"],\n" + + " \"command\": [\"sh\", \"-c\", \"lavinmqctl change_password " + BrokerAdminUser + " '" + c.Broker + "' && echo " + fp + " > " + brokerAdminMarker + "\"],\n" + " \"verify\": [\"sh\", \"-c\", \"read m < " + brokerAdminMarker + " && [ \\\"$m\\\" = " + fp + " ]\"]\n" + " }," if bundle, err = replaceOnce(bundle, templateBrokerReady, action, "the broker's readiness check"); err != nil { diff --git a/internal/bootstrap/rootsecrets_test.go b/internal/bootstrap/rootsecrets_test.go index ced776a..c23f864 100644 --- a/internal/bootstrap/rootsecrets_test.go +++ b/internal/bootstrap/rootsecrets_test.go @@ -121,3 +121,47 @@ func TestRootSecretsAreKeptAcrossRuns(t *testing.T) { t.Fatal("a dry run wrote a secret") } } + +// Nothing the installer says after making the credentials contains them. +func TestTheTranscriptNeverSaysTheCredentials(t *testing.T) { + var said []string + say := Masking(func(l string) { said = append(said, l) }, RootCredentials{Store: "STORE-PW", Broker: "BROKER-PW"}) + say("created context-schemas (docker run -e MESH_STORE_INVENTORY=postgres://postgres:STORE-PW@127.0.0.1:5432/inventory)") + say("failed broker-admin (sh -c lavinmqctl change_password guest 'BROKER-PW' && echo x)") + for _, l := range said { + if strings.Contains(l, "STORE-PW") || strings.Contains(l, "BROKER-PW") { + t.Errorf("said a credential: %s", l) + } + } + if !strings.Contains(said[0], "postgres:…@") || !strings.Contains(said[1], "guest '…'") { + t.Errorf("the lines were not the same lines with the values masked: %v", said) + } +} + +// The broker-admin marker is written with a line ending, because the verify reads it with `read`. +func TestTheBrokerAdminMarkerHasALineEnding(t *testing.T) { + template, err := os.ReadFile("../../examples/foundation-first-node.lock") + if err != nil { + t.Skip("no example bundle beside this checkout") + } + r, err := Rewrite(template, "sha256:"+strings.Repeat("ab", 32)) + if err != nil { + t.Fatal(err) + } + if _, err := RewriteRoot(&r, RootCredentials{Store: "s", Broker: "b"}); err != nil { + t.Fatal(err) + } + for _, res := range r.Declaration.Resources { + a, ok := res.(*declaration.Action) + if !ok || a.ID != "broker-admin" { + continue + } + cmd := strings.Join(a.Command, " ") + if !strings.Contains(cmd, "&& echo ") || strings.Contains(cmd, "printf %s") { + t.Errorf("the marker is written without a line ending: %s", cmd) + } + if !strings.Contains(strings.Join(a.Verify, " "), "read m <") { + t.Errorf("the verify does not read the marker: %v", a.Verify) + } + } +}