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.
263 lines
11 KiB
Go
263 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The shape of the leak in hq issue 268: a server password announced at start and a database URI
|
|
// echoed whole. The values are made up for the test.
|
|
const (
|
|
serverPassword = "Zq8-server-pass_word"
|
|
dbPassword = "Db_pa55-word-xyz"
|
|
)
|
|
|
|
var lettaEnv = []string{
|
|
"LETTA_PG_URI=postgresql://letta@db:5432/letta",
|
|
"LETTA_SERVER_PASSWORD=" + serverPassword,
|
|
"OTHER_URI=postgresql://other:" + dbPassword + "@db:5432/other",
|
|
"PGPASSFILE=/run/secrets/pgpass",
|
|
"SECURE=true",
|
|
"AUTH_URL=https://id.example/auth",
|
|
"TOKEN_TTL=3600",
|
|
"POSTGRES_PASSWORD=letta",
|
|
"TZ=Europe/Brussels",
|
|
}
|
|
|
|
func TestOnlyValuesNamedAsSecretsAndPasswordsInURIsAreKnown(t *testing.T) {
|
|
got := map[string]string{}
|
|
for _, s := range secretsIn(lettaEnv) {
|
|
got[s.Name] = s.Value
|
|
}
|
|
if got["LETTA_SERVER_PASSWORD"] != serverPassword || got["OTHER_URI (the password in its URI)"] != dbPassword {
|
|
t.Fatalf("missed a secret: %v", keys(got))
|
|
}
|
|
for _, not := range []string{"LETTA_PG_URI (the password in its URI)", "PGPASSFILE", "SECURE", "AUTH_URL", "TOKEN_TTL", "POSTGRES_PASSWORD", "TZ"} {
|
|
if _, ok := got[not]; ok {
|
|
t.Errorf("%s taken for a secret", not)
|
|
}
|
|
}
|
|
}
|
|
|
|
func scanMachine(logs string) *fake {
|
|
env, _ := json.Marshal(lettaEnv)
|
|
return (&fake{}).
|
|
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
|
|
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
|
|
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: string(env) + "\n"}).
|
|
on("docker logs --timestamps --tail 5000 aaaaaaaaaaaa", Ran{Stdout: logs})
|
|
}
|
|
|
|
const leakyLog = "2026-10-05T19:16:40Z External Postgres configuration detected, using postgresql://letta@db:5432/letta\n" +
|
|
"2026-10-05T19:16:41Z Creating engine postgresql://other:" + dbPassword + "@db:5432/other\n" +
|
|
"2026-10-05T19:16:42Z ▶ Using secure mode with password: " + serverPassword + "\n" +
|
|
"2026-10-05T19:16:43Z connecting to mongodb://app:s3cr3t-elsewhere@mongo:27017\n" +
|
|
"2026-10-05T19:16:44Z Using database: postgresql://letta:***@db:5432/letta\n" +
|
|
"2026-10-05T19:20:42Z ▶ Using secure mode with password: " + serverPassword + "\n"
|
|
|
|
func TestAScanNamesEachPrintedSecretAndNeverItsValue(t *testing.T) {
|
|
got, err := client(scanMachine(leakyLog), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(got)
|
|
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
|
|
if strings.Contains(string(raw), v) {
|
|
t.Fatalf("the answer carries a secret's value: %s", raw)
|
|
}
|
|
}
|
|
leaks := got["leaks"].([]Leak)
|
|
byName := map[string]Leak{}
|
|
for _, l := range leaks {
|
|
byName[l.Secret] = l
|
|
if l.Container != "mesh-web" || l.Module != "hello-web" || l.HeldBy != "hello-web.server" {
|
|
t.Errorf("finding not named by its container and module: %+v", l)
|
|
}
|
|
}
|
|
if l := byName["LETTA_SERVER_PASSWORD"]; l.Lines != 2 || l.First != "2026-10-05T19:16:42Z" || l.Last != "2026-10-05T19:20:42Z" {
|
|
t.Errorf("server password: %+v", l)
|
|
}
|
|
if l := byName["OTHER_URI (the password in its URI)"]; l.Lines != 1 {
|
|
t.Errorf("password in a URI from the environment: %+v", l)
|
|
}
|
|
if l := byName["a password inside a URI (not from its environment)"]; l.Lines != 1 {
|
|
t.Errorf("a URI's password by its shape (and not a masked one): %+v", l)
|
|
}
|
|
if len(leaks) != 3 || got["containers_scanned"] != 1 {
|
|
t.Errorf("leaks %d, scanned %v (only the mesh's)", len(leaks), got["containers_scanned"])
|
|
}
|
|
}
|
|
|
|
func TestACleanLogIsSaidToBeClean(t *testing.T) {
|
|
got, err := client(scanMachine("2026-10-05T19:16:40Z started\n"), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["count"] != 0 || !strings.HasPrefix(got["verdict"].(string), "no container") {
|
|
t.Errorf("%v", got)
|
|
}
|
|
}
|
|
|
|
func TestAContainerWhoseLogCannotBeReadIsSaidSoRatherThanCalledClean(t *testing.T) {
|
|
f := scanMachine("")
|
|
f.rules = append([]rule{{"docker logs", Ran{Status: 1, Stderr: "Error response from daemon: configured logging driver does not support reading\n"}}}, f.rules...)
|
|
got, err := client(f, 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got["unread"].([]map[string]string)) != 1 || got["containers_scanned"] != 0 {
|
|
t.Errorf("%v", got)
|
|
}
|
|
}
|
|
|
|
func TestLogsRedactWhatTheContainerPrintedOfItsSecrets(t *testing.T) {
|
|
env, _ := json.Marshal(lettaEnv)
|
|
f := (&fake{}).
|
|
on("docker logs", Ran{Stdout: leakyLog}).
|
|
on("docker container inspect --format {{json .Config.Env}} letta", Ran{Stdout: string(env)})
|
|
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(got)
|
|
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
|
|
if strings.Contains(string(raw), v) {
|
|
t.Fatalf("docker_logs answered a secret: %s", raw)
|
|
}
|
|
}
|
|
lines := got["lines"].([]string)
|
|
if !strings.Contains(lines[2], "[redacted: LETTA_SERVER_PASSWORD]") ||
|
|
!strings.Contains(lines[3], "mongodb://app:[redacted: a password in a URI]@mongo") ||
|
|
!strings.Contains(lines[4], "letta:***@db") || got["redacted"] != 4 {
|
|
t.Errorf("%v %v", lines, got["redacted"])
|
|
}
|
|
}
|
|
|
|
func TestLogsWithoutTheEnvironmentStillHideAURIsPasswordAndSaySo(t *testing.T) {
|
|
f := (&fake{}).on("docker logs", Ran{Stdout: leakyLog})
|
|
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(got)
|
|
if strings.Contains(string(raw), dbPassword) || got["redaction"] == nil {
|
|
t.Errorf("%s", raw)
|
|
}
|
|
}
|
|
|
|
func keys(m map[string]string) []string {
|
|
out := []string{}
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
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)
|
|
}
|
|
}
|