Keep secrets off command lines the runtime records (hq issue 282)
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user