Keep secrets off command lines the runtime records (hq issue 282)
mesh/merge-gate pass: builds docker, keycloak, minio, mosquitto → ace, g14, novox, shanks; no bus step; 2 wait(s) for a person; every machine composes with…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

mosquitto passed the broker's admin password to mosquitto_ctrl as -P on
every docker exec, and the container runtime keeps every exec's command
line in its event stream, where docker_events returned it. The admin
credentials now reach mosquitto_ctrl as a 0600 options file fed on
stdin, client passwords at its own prompt, and an argv carrying a secret
is refused before it runs. The admin secret says it is taken at start:
the bootstrap re-runs when the mesh replaces it and re-keys the broker
online from the value it last applied, so it can be rotated.

docker_events redacts what an exec's command line carried, and
docker_secrets_in_events names such secrets by name. keycloak's repair
hands kcadm its passwords through KC_CLI_PASSWORD; minio gives mc its
root alias through MC_HOST_mesh.
This commit is contained in:
jochen
2026-10-07 01:37:21 +02:00
parent 950e52ff64
commit 13b7562c47
14 changed files with 892 additions and 83 deletions
@@ -154,3 +154,109 @@ func keys(m map[string]string) []string {
}
return out
}
// The shape of the leak in hq issue 282: a broker's admin password on an exec's command line. The
// values are made up for the test.
const (
adminPassword = "Adm1n-pass_word-xyz"
clientPassword = "Cl1ent-pass_word-abc"
)
func TestACommandLineIsShownWithoutTheSecretsItCarried(t *testing.T) {
for _, tc := range []struct{ command, mark string }{
{"mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P " + adminPassword + " dynsec listClients", "the word after -P"},
{"mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec createClient alice -p " + clientPassword, "the word after -p in a mosquitto_ctrl"},
{"mosquitto_ctrl -o /tmp/x dynsec setClientPassword alice " + clientPassword, "the password given to dynsec setClientPassword"},
{"redis-cli -a " + adminPassword + " ping", "the word after -a"},
{"env PGPASSWORD=" + adminPassword + " psql -U app", "the value of PGPASSWORD"},
{"tool --password=" + adminPassword, "the value of --password"},
{"psql postgresql://app:" + adminPassword + "@db/app", "a password in a URI"},
} {
shown, names := redactCommand(tc.command, nil)
if strings.Contains(shown, adminPassword) || strings.Contains(shown, clientPassword) {
t.Errorf("%q: still carries the value: %q", tc.command, shown)
}
if len(names) == 0 || !strings.Contains(strings.Join(names, "|"), tc.mark) {
t.Errorf("%q: named %v, want %q", tc.command, names, tc.mark)
}
}
// A port, a path and a plain command are not secrets.
for _, plain := range []string{
"mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec listClients",
"/usr/bin/lavinmqctl status",
"sh -c umask 077\nf=$(mktemp) || exit 1 mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec getClient alice",
"pg_dump -Fc -f /dumps/app.dump app",
} {
if shown, names := redactCommand(plain, nil); shown != plain || len(names) > 0 {
t.Errorf("%q: redacted %v as %q", plain, names, shown)
}
}
// What the container's environment holds is known by its name, wherever it appears.
known := []knownSecret{{"SERVER_PASSWORD", adminPassword}}
if shown, names := redactCommand("app login "+adminPassword, known); strings.Contains(shown, adminPassword) ||
len(names) != 1 || names[0] != "SERVER_PASSWORD" {
t.Errorf("an environment secret on a command line: %q %v", shown, names)
}
}
const execEvents = `{"Type":"container","Action":"exec_create: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec listClients","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e1"}},"timeNano":1791320000000000000}
{"Type":"container","Action":"exec_start: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec listClients","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e1"}},"timeNano":1791320000000100000}
{"Type":"container","Action":"exec_die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","exitCode":"0"}},"timeNano":1791320000000200000}
{"Type":"container","Action":"exec_create: /usr/bin/healthcheck","Actor":{"ID":"bbbbbbbbbbbbbbbb","Attributes":{"name":"other"}},"timeNano":1791320060000000000}
{"Type":"container","Action":"exec_create: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec getClient a","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e2"}},"timeNano":1791320120000000000}
`
func TestEventsShowAnExecsCommandLineWithoutItsSecrets(t *testing.T) {
f := (&fake{}).
on("docker events", Ran{Stdout: execEvents}).
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: "[]\n"})
got, err := client(f, 1000).Events(context.Background(), 30, "", 100, true)
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), adminPassword) {
t.Fatalf("the answer carries the value: %s", b)
}
if !strings.Contains(string(b), "[redacted: the word after -P") || got["leak"] == nil {
t.Fatalf("not marked as redacted: %s", b)
}
}
func TestAScanOfExecEventsNamesEachSecretAndNeverItsValue(t *testing.T) {
f := (&fake{}).
on("docker events", Ran{Stdout: execEvents}).
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: "[]\n"})
got, err := client(f, 1000).SecretsInEvents(context.Background(), 60)
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), adminPassword) {
t.Fatalf("the finding carries the value: %s", b)
}
leaks := got["leaks"].([]CommandLeak)
if len(leaks) != 1 || leaks[0].Container != "mosquitto" || leaks[0].Module != "mosquitto" || leaks[0].Execs != 2 ||
leaks[0].Program != "mosquitto_ctrl" || !strings.Contains(leaks[0].Secret, "-P") {
t.Fatalf("leaks: %+v", leaks)
}
if got["execs_read"] != 3 {
t.Fatalf("read %v exec_create events, want 3 (a start repeats a create and is not counted)", got["execs_read"])
}
if !f.ran("docker events --since 60m --until 0s --filter type=container --filter event=exec_create") {
t.Fatalf("not asked for exec creations only: %+v", f.calls)
}
}
func TestInspectShowsACommandLineWithoutItsSecrets(t *testing.T) {
obj := `[{"Path":"mosquitto_ctrl","Args":["-P","` + adminPassword + `","dynsec","listClients"],"Config":{"Env":["A=b"],"Cmd":["mosquitto_ctrl","-P","` + adminPassword + `"],"Labels":{}}}]`
f := (&fake{}).on("docker container inspect", Ran{Stdout: obj})
got, err := client(f, 1000).Inspect(context.Background(), "mosquitto")
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), adminPassword) || !strings.Contains(string(b), "[redacted:") {
t.Fatalf("inspect: %s", b)
}
}