diff --git a/modules/docker/README.md b/modules/docker/README.md index 24815f6..125b3de 100644 --- a/modules/docker/README.md +++ b/modules/docker/README.md @@ -127,7 +127,8 @@ A failure is an error naming how it failed, never an empty answer. |---|---|---| | `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name | | `docker_inspect` | r | one container whole, **environment values left out** (names kept) | -| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) | +| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000); **a secret the container printed is shown as `[redacted: ]`** | +| `docker_secrets_in_logs` | r | which containers printed a secret they were given, **by name, never by value** (below) | | `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first | | `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply | | `docker_top` | r | the processes inside one container | @@ -142,6 +143,31 @@ A failure is an error naming how it failed, never an empty answer. | `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more | | `docker_ports` | r | every published port, and the containers on the host's network | +## Secrets in a container's own log (hq issue 268) + +Software prints what it is given: a server announcing its password as it starts, a startup script +echoing the database URI it connects with. The container's log is then a copy of the secret, held by +whoever reads it — this bundle's `docker_logs` among them. `docker_secrets_in_logs` reads the last +lines of each container's log (the mesh's by default, 5000 lines each, at most 50000) and compares +them with: + +- the values of the container's environment whose names say they are secrets (`PASSWORD`, `SECRET`, + `TOKEN`, `API_KEY`, …; not a path, a URL, a number or a switch), as given and URL-encoded; +- the password inside any URI its environment holds; +- the shape `scheme://user:password@`, anywhere in a line, whatever the source — a password a program + already masked (`***`) is not one. + +A finding names the container, the module, the assignment and the secret's variable, with how many +lines carry it and the first and last time. **It never carries the value or the line.** A secret +delivered only as a mounted file, never in the environment, is not known here — the bundle runs as the +operator account, which cannot read the host's 0600 files — and is caught only inside a URI. + +`docker_logs` redacts the same values before it answers, because what it answers is read by agents +and kept in their transcripts. Its answer says how many it redacted, and points here. + +A finding is a secret to rotate once the program stops printing it; recreating the container drops +its old log (the runtime's file goes with the container). + ## Tests ``` @@ -158,6 +184,7 @@ The tests run against a fake runner and cover: - the restore note on a mesh-held act; - prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`; - the log merge; +- a printed secret found by name and never answered by value, in the scan and in `docker_logs`; - size parsing; - what the daemon has not yet taken; - event filtering; diff --git a/modules/docker/cmd/docker-tools/docker.go b/modules/docker/cmd/docker-tools/docker.go index 9f08014..d9a65ad 100644 --- a/modules/docker/cmd/docker-tools/docker.go +++ b/modules/docker/cmd/docker-tools/docker.go @@ -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. diff --git a/modules/docker/cmd/docker-tools/docker_test.go b/modules/docker/cmd/docker-tools/docker_test.go index 03d1b98..4adfced 100644 --- a/modules/docker/cmd/docker-tools/docker_test.go +++ b/modules/docker/cmd/docker-tools/docker_test.go @@ -8,6 +8,7 @@ import ( "os" "reflect" "strings" + "sync" "testing" "time" ) @@ -19,6 +20,7 @@ type call struct { // fake answers each command by the first rule whose prefix matches "name arg arg…". type fake struct { + mu sync.Mutex rules []rule calls []call } @@ -31,6 +33,8 @@ type rule struct { func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f } func (f *fake) run(_ context.Context, name string, args ...string) Ran { + f.mu.Lock() + defer f.mu.Unlock() f.calls = append(f.calls, call{name, args}) line := strings.Join(append([]string{name}, args...), " ") for _, r := range f.rules { diff --git a/modules/docker/cmd/docker-tools/main.go b/modules/docker/cmd/docker-tools/main.go index 6d1755b..d4dfee1 100644 --- a/modules/docker/cmd/docker-tools/main.go +++ b/modules/docker/cmd/docker-tools/main.go @@ -71,8 +71,9 @@ func tools(c *Client) []stdio.Tool { }, }, { - Name: "docker_logs", - Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB).", + Name: "docker_logs", + Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB). " + + "A secret the container was given that it printed is shown as [redacted: ], and so is a password inside a URI.", Input: map[string]any{ "container": containerArg, "lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"}, @@ -90,6 +91,27 @@ func tools(c *Client) []stdio.Tool { return c.Logs(ctx, ref, n, optional(args, "since")) }, }, + { + Name: "docker_secrets_in_logs", + Description: "Which containers printed a secret they were given into their own log — by container, module and the secret's name, never its value: " + + "each one's recent lines compared with the values of its environment named like a secret and the passwords in its URIs, and any URI carrying a password. " + + "A finding is a secret to rotate once the program stops printing it (novox/hq issue 268).", + Input: map[string]any{ + "held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: the mesh's (default), every container, or the others"}, + "lines": map[string]any{"type": "integer", "description": "how many lines from the end of each log (default 5000, at most 50000)"}, + }, + Run: func(args map[string]any) (any, error) { + n, err := bounded(args, "lines", 5000, 50000) + if err != nil { + return nil, err + } + held := optional(args, "held") + if held == "" { + held = "mesh" + } + return c.SecretsInLogs(ctx, held, n) + }, + }, { Name: "docker_stats", Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.", diff --git a/modules/docker/cmd/docker-tools/secrets.go b/modules/docker/cmd/docker-tools/secrets.go new file mode 100644 index 0000000..12c31c7 --- /dev/null +++ b/modules/docker/cmd/docker-tools/secrets.go @@ -0,0 +1,289 @@ +package main + +// A container's secrets in its own output (novox/hq issue 268). +// +// **The leak this catches.** Software prints what it was given: a server that announces its +// password when it starts in secure mode, a startup script that echoes the database URI it +// connects with, password and all. The container's log is then a copy of the secret that every +// reader of the log holds — this bundle's docker_logs, the journal where a container logs there, +// and whatever kept a transcript of either. Nothing in the mesh noticed, because nothing looked. +// +// **What is known here, and what is not.** A container's environment is readable through the +// runtime (docker inspect), so its values can be compared with what it printed: a variable named +// like a secret (PASSWORD, SECRET, TOKEN, KEY, …) and the password inside any URI a variable holds. +// Beside that, a credential-bearing URI anywhere in a line (`scheme://user:password@`) is caught +// by its shape, whatever the source. A secret handed only as a mounted file and never in the +// environment is not known to this bundle — it runs as the operator account, which cannot read +// the files the host writes at 0600 — and is caught only if the program prints it inside a URI. +// +// **Never the value.** A finding names the container, the module and the variable; it carries +// no value and no line. A tool that quoted the leak to report it would be a second leak. + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// secretName is a variable name that says its value is a secret. +var secretName = regexp.MustCompile(`(?i)(pass(word|wd|phrase)?|secret|token|api_?key|private_?key|access_?key|credential|auth)`) + +// notAValue is a name that says its value is where a secret is, not the secret: a file or a path. +var notAValue = regexp.MustCompile(`(?i)(_FILE|FILE|_PATH|_DIR)$`) + +// uriPassword is a URI carrying a password in its userinfo: scheme://user:password@. +var uriPassword = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@'"]*:([^\s/@'"]+)@`) + +// masked is a password a program already hid: ***, xxx, , [REDACTED]. +var masked = regexp.MustCompile(`^(\*+|x+|X+|<[^>]*>|\[[^\]]*\]|%2A+)$`) + +// ordinary is a value under a secret's name that is not one: a path, an address, a number, a switch. +var ordinary = regexp.MustCompile(`^(/.*|[A-Za-z][A-Za-z0-9+.-]*://.*|[0-9.]+[a-z]?|(?i:true|false|yes|no|on|off|none|null))$`) + +// leastSecret is the shortest value compared as a secret: a shorter one matches ordinary words. +const leastSecret = 6 + +// knownSecret is one value a container was given, by the name it came under. +type knownSecret struct { + Name string + Value string +} + +// secretsIn are the values in a container's environment that must never appear in its output. +func secretsIn(env []string) []knownSecret { + var out []knownSecret + seen := map[string]bool{} + add := func(name, value string) { + if len(value) < leastSecret || masked.MatchString(value) || seen[name+"\x00"+value] { + return + } + seen[name+"\x00"+value] = true + out = append(out, knownSecret{name, value}) + } + for _, e := range env { + name, value, ok := strings.Cut(e, "=") + if !ok || value == "" { + continue + } + for _, m := range uriPassword.FindAllStringSubmatch(value, -1) { + add(name+" (the password in its URI)", m[1]) + if dec, err := url.PathUnescape(m[1]); err == nil && dec != m[1] { + add(name+" (the password in its URI)", dec) + } + } + if secretName.MatchString(name) && !notAValue.MatchString(name) && !ordinary.MatchString(value) { + add(name, value) + } + } + return out +} + +// forms are the ways a value may appear printed: as given, and URL-encoded. +func forms(value string) []string { + out := []string{value} + for _, f := range []string{url.QueryEscape(value), url.PathEscape(value)} { + if f != value && !contains(out, f) { + out = append(out, f) + } + } + return out +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// leaksIn is, per secret name, how many lines carry that secret; and how many carry a URI with a +// password that is none of the known ones. The lines are read and forgotten. +func leaksIn(lines []string, known []knownSecret) (byName map[string][]string, uris []string) { + byName = map[string][]string{} + for _, l := range lines { + hit := false + for _, s := range known { + for _, f := range forms(s.Value) { + if strings.Contains(l, f) { + byName[s.Name] = append(byName[s.Name], stamp(l)) + hit = true + break + } + } + } + if hit { + continue + } + for _, m := range uriPassword.FindAllStringSubmatch(l, -1) { + if !masked.MatchString(m[1]) { + uris = append(uris, stamp(l)) + break + } + } + } + return byName, uris +} + +// redact is a line with every known secret, and every password inside a URI, replaced by a mark +// naming what was there. +func redact(line string, known []knownSecret) (string, int) { + n := 0 + for _, s := range known { + for _, f := range forms(s.Value) { + if c := strings.Count(line, f); c > 0 { + line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]") + n += c + } + } + } + line = uriPassword.ReplaceAllStringFunc(line, func(m string) string { + sub := uriPassword.FindStringSubmatch(m) + if masked.MatchString(sub[1]) { + return m + } + n++ + return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@" + }) + return line, n +} + +// stamp is the timestamp leading a line `docker logs --timestamps` printed. +func stamp(line string) string { + t, _, _ := strings.Cut(line, " ") + return t +} + +// envOf is one container's environment, values included — kept inside this process. +func (c *Client) envOf(ctx context.Context, ref string) ([]string, error) { + out, err := c.docker(ctx, "container", "inspect", "--format", "{{json .Config.Env}}", ref) + if err != nil { + return nil, err + } + var env []string + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &env); err != nil { + return nil, fmt.Errorf("docker inspect answered an environment that is not JSON") + } + return env, nil +} + +// Leak is one secret a container printed: by name, never by value. +type Leak struct { + Container string `json:"container"` + HeldBy string `json:"held_by,omitempty"` + Module string `json:"module,omitempty"` + Secret string `json:"secret"` + Lines int `json:"lines"` + First string `json:"first"` + Last string `json:"last"` +} + +// ScanBudget is how long a scan may take: below the runtime's thirty-second call limit, so a scan of +// a machine with many containers answers what it read rather than nothing. ScanWidth is how many +// containers are read at once. +const ( + ScanBudget = 25 * time.Second + ScanWidth = 6 +) + +// scanned is what one container's log held. +type scanned struct { + leaks []Leak + lines int + why string +} + +// scanOne reads one container's environment and log, and keeps only what was printed, by name. +func (c *Client) scanOne(ctx context.Context, ct Container, tail int) scanned { + env, err := c.envOf(ctx, ct.ID) + if err != nil { + return scanned{why: err.Error()} + } + lines, err := c.logLines(ctx, []string{"logs", "--timestamps", "--tail", strconv.Itoa(tail), ct.ID}) + if err != nil { + if ctx.Err() != nil { + return scanned{why: "not read within the scan's " + ScanBudget.String()} + } + return scanned{why: err.Error()} + } + byName, uris := leaksIn(lines, secretsIn(env)) + if len(uris) > 0 { + byName["a password inside a URI (not from its environment)"] = uris + } + names := make([]string, 0, len(byName)) + for n := range byName { + names = append(names, n) + } + sort.Strings(names) + out := scanned{lines: len(lines)} + for _, n := range names { + at := byName[n] + sort.Strings(at) + out.leaks = append(out.leaks, Leak{Container: ct.Name, HeldBy: ct.HeldBy, Module: ct.Module, Secret: n, + Lines: len(at), First: at[0], Last: at[len(at)-1]}) + } + return out +} + +// SecretsInLogs scans the last `tail` lines of each container — the mesh's, or every one — for the +// secrets it was given. A container whose log could not be read is listed as unread, never as clean. +func (c *Client) SecretsInLogs(ctx context.Context, held string, tail int) (map[string]any, error) { + all, err := c.Containers(ctx, held, "", "") + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, ScanBudget) + defer cancel() + results := make([]scanned, len(all)) + var wg sync.WaitGroup + slots := make(chan struct{}, ScanWidth) + for i, ct := range all { + wg.Add(1) + go func(i int, ct Container) { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + results[i] = c.scanOne(ctx, ct, tail) + case <-ctx.Done(): + results[i] = scanned{why: "not read within the scan's " + ScanBudget.String()} + } + }(i, ct) + } + wg.Wait() + + leaks := []Leak{} + unread := []map[string]string{} + count, read := 0, 0 + for i, r := range results { + if r.why != "" { + unread = append(unread, map[string]string{"container": all[i].Name, "why": r.why}) + continue + } + count++ + read += r.lines + leaks = append(leaks, r.leaks...) + } + verdict := "no container printed a secret it was given in the lines read" + if len(leaks) > 0 { + verdict = fmt.Sprintf("%d secret(s) printed into container logs: rotate each one after the program stops printing it, "+ + "and recreate the container to drop its old log", len(leaks)) + } + if len(unread) > 0 { + verdict += fmt.Sprintf("; %d container(s) not read, so not known to be clean", len(unread)) + } + return map[string]any{ + "verdict": verdict, "leaks": leaks, "count": len(leaks), "containers_scanned": count, "lines_read": read, + "unread": unread, + "knows": "values in each container's environment named like a secret, the password in any URI it holds, and any " + + "URI carrying a password; a secret delivered only as a mounted file is caught only inside a URI", + }, nil +} diff --git a/modules/docker/cmd/docker-tools/secrets_test.go b/modules/docker/cmd/docker-tools/secrets_test.go new file mode 100644 index 0000000..2f9c680 --- /dev/null +++ b/modules/docker/cmd/docker-tools/secrets_test.go @@ -0,0 +1,156 @@ +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 +} diff --git a/modules/docker/module.json b/modules/docker/module.json index 0a66a63..3ee97da 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -16,6 +16,7 @@ "docker_list", "docker_inspect", "docker_logs", + "docker_secrets_in_logs", "docker_stats", "docker_start", "docker_stop",