docker: find and hide secrets a container printed into its log (hq issue 268)

letta printed two passwords into its log for weeks and nothing noticed,
and docker_logs handed them to whoever asked. docker_secrets_in_logs
compares each container's recent lines with the secret-named values of
its environment, the passwords in its URIs, and any URI carrying a
password, and names what it found by container, module and variable -
never the value. docker_logs redacts the same values before answering.
This commit is contained in:
jochen
2026-10-06 02:13:42 +02:00
parent f9f27d4878
commit bbb67e41a0
7 changed files with 541 additions and 13 deletions
+39 -10
View File
@@ -380,6 +380,44 @@ func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (
args = append(args, "--since", since)
}
args = append(args, ref)
all, err := c.logLines(ctx, args)
if err != nil {
return nil, err
}
if len(all) > tail {
all = all[len(all)-tail:]
}
// What the container printed of the secrets it was given is not shown (novox/hq issue 268): the
// answer of this tool is read by agents and kept in their transcripts, which would make it a
// second copy of the leak. Redacted before the lines are cut, so a cut never splits a value.
answer := map[string]any{"container": ref}
env, envErr := c.envOf(ctx, ref)
known := secretsIn(env)
redacted := 0
for i, l := range all {
var n int
all[i], n = redact(l, known)
redacted += n
}
if envErr != nil {
answer["redaction"] = "only passwords inside URIs: the environment could not be read (" + envErr.Error() + ")"
}
if redacted > 0 {
answer["redacted"] = redacted
answer["leak"] = "this container printed secrets it was given; docker_secrets_in_logs names them (novox/hq issue 268)"
}
const most = 4096
for i, l := range all {
if len(l) > most {
all[i] = l[:most] + "…"
}
}
answer["lines"], answer["count"] = all, len(all)
return answer, nil
}
// logLines runs `docker logs …` and answers both streams' lines merged in the order written.
func (c *Client) logLines(ctx context.Context, args []string) ([]string, error) {
r := c.Run(ctx, "docker", args...)
program := "docker"
if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) {
@@ -392,16 +430,7 @@ func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (
// Both streams carry the container's lines, each led by its timestamp, so they merge in order.
all := append(lines(r.Stdout), lines(r.Stderr)...)
sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] })
if len(all) > tail {
all = all[len(all)-tail:]
}
const most = 4096
for i, l := range all {
if len(l) > most {
all[i] = l[:most] + "…"
}
}
return map[string]any{"container": ref, "lines": all, "count": len(all)}, nil
return all, nil
}
// Stat is one container's use of the machine now.