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
+70 -10
View File
@@ -345,6 +345,27 @@ func (c *Client) Inspect(ctx context.Context, ref string) (map[string]any, error
return nil, fmt.Errorf("docker inspect answered something that is not one container")
}
obj := got[0]
// A container's command line is shown without the secrets it carries, as an exec's is (novox/hq
// issue 282): known from its environment, and by shape.
var known []knownSecret
if cfg, ok := obj["Config"].(map[string]any); ok {
if env, ok := cfg["Env"].([]any); ok {
list := make([]string, 0, len(env))
for _, e := range env {
list = append(list, fmt.Sprint(e))
}
known = secretsIn(list)
}
for _, k := range []string{"Cmd", "Entrypoint"} {
if words, ok := cfg[k].([]any); ok {
cfg[k] = redactWords(words, known)
}
}
}
if words, ok := obj["Args"].([]any); ok {
path, _ := obj["Path"].(string)
obj["Args"] = redactWords(append([]any{path}, words...), known)[1:]
}
if cfg, ok := obj["Config"].(map[string]any); ok {
if env, ok := cfg["Env"].([]any); ok {
names := []string{}
@@ -979,24 +1000,29 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
if err != nil {
return nil, err
}
raw, err := jsonLines[struct {
Type, Action string
Actor struct {
ID string
Attributes map[string]string
}
TimeNano int64 `json:"timeNano"`
}](out)
raw, err := jsonLines[runtimeEvent](out)
if err != nil {
return nil, err
}
// An exec's command line is shown without the secrets it carried (novox/hq issue 282): this answer
// is read by agents and kept in their transcripts, which would make it a second copy of the leak.
envs := &envCache{c: c, ctx: ctx}
redacted := map[string]bool{}
events := []map[string]any{}
for _, e := range raw {
if !execs && strings.HasPrefix(e.Action, "exec_") {
continue
}
action := e.Action
if verb, command, ok := execCommand(action); ok {
shown, names := redactCommand(command, envs.of(e.Actor.ID))
action = verb + ": " + shown
for _, n := range names {
redacted[n] = true
}
}
_, held := e.Actor.Attributes[MeshLabel]
ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": e.Action,
ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": action,
"id": shortID(e.Actor.ID), "name": e.Actor.Attributes["name"]}
if e.Type == "container" {
ev["mesh_held"] = held
@@ -1011,7 +1037,41 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
if len(events) > limit {
events = events[len(events)-limit:]
}
return map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}, nil
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}
if len(redacted) > 0 {
answer["redacted"] = sortedSet(redacted)
answer["leak"] = "exec command lines carried secrets, which the runtime keeps in its events; docker_secrets_in_events names them (novox/hq issue 282)"
}
return answer, nil
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// redactWords is a command's words with what redactCommand hides hidden, word by word.
func redactWords(words []any, known []knownSecret) []any {
list := make([]string, len(words))
for i, w := range words {
list[i] = fmt.Sprint(w)
}
shapes := secretsOnCommandLine(list)
out := make([]any, len(list))
for i, w := range list {
for _, s := range shapes {
if strings.Contains(w, s.Value) {
w = strings.ReplaceAll(w, s.Value, "[redacted: "+s.Name+"]")
}
}
w, _ = redact(w, known)
out[i] = w
}
return out
}
// restartOnly are the daemon keys the runtime reads only when it starts: a reload leaves them as