package main // A unit's secrets in its own journal (novox/hq issue 268, issue 282, as the docker module reads a // container's log). // // **The leak this hides.** Software prints what it was given — a server announcing its password, a // script echoing the URI it connects with, a command line logged with its password flag — and the // journal keeps it. The journal verb's answer is read by agents and kept in their transcripts, which // would make it a second copy of the leak; and with a window and a filter on the verb, a caller could // otherwise go looking for one. // // **What is known here.** The values of the unit's own Environment= named like a secret (PASSWORD, // SECRET, TOKEN, KEY, …) and the password inside any URI one of them holds; and, whatever the source, // what a line carries by its shape: a credential-bearing URI (`scheme://user:password@`), the word after // a flag that takes a password, a NAME=value whose name says secret. A secret given only in an // EnvironmentFile= or a credential is not known — the unit's files are root's — and is caught only by // its shape. // // Copied from the docker module's secrets.go and cmdline.go, narrowed to what a journal line needs: each // module is its own Go module, and the two share no package. import ( "net/url" "regexp" "strings" ) // 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 // passwordFlags take a secret as their next word, or after `=`, whatever the program. var passwordFlags = map[string]bool{ "-P": true, "--password": true, "--pass": true, "--passwd": true, "--secret": true, "--secret-key": true, "--token": true, "--api-key": true, "--apikey": true, "--auth": true, } // knownSecret is one value a unit was given, by the name it came under. type knownSecret struct { Name string Value string } // secretsIn are the values in a unit's environment that must never appear in what it answers. 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 } // environment is the words of systemd's Environment= property as `systemctl show --value` prints it: // separated by spaces, a word holding one quoted in C style. func environment(value string) []string { var out []string var word strings.Builder quote := byte(0) in := false for i := 0; i < len(value); i++ { c := value[i] switch { case quote != 0 && c == '\\' && i+1 < len(value): i++ word.WriteByte(value[i]) case quote != 0 && c == quote: quote = 0 case quote == 0 && (c == '"' || c == '\''): quote, in = c, true case quote == 0 && (c == ' ' || c == '\t' || c == '\n'): if in { out = append(out, word.String()) word.Reset() in = false } default: word.WriteByte(c) in = true } } if in { out = append(out, word.String()) } 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 && !has(out, f) { out = append(out, f) } } return out } func has(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false } // shaped are the values a line carries by their shape: the word after a password flag, or the value of // one given with `=`, and a NAME=value whose name says secret. func shaped(line string) []knownSecret { var out []knownSecret add := func(name, value string) { value = strings.Trim(value, `"',;`) if len(value) < leastSecret || masked.MatchString(value) || ordinary.MatchString(value) { return } out = append(out, knownSecret{name, value}) } words := strings.Fields(line) for i, w := range words { if flag, value, ok := strings.Cut(w, "="); ok && strings.HasPrefix(flag, "-") { if passwordFlags[flag] { add("the value of "+flag, value) } continue } if name, value, ok := strings.Cut(w, "="); ok && name != "" && secretName.MatchString(name) && !notAValue.MatchString(name) && !strings.ContainsAny(name, "/:") { add("the value of "+name, value) continue } if i+1 < len(words) && passwordFlags[w] { add("the word after "+w, words[i+1]) } } return out } // redact is a line with every known secret, every value its shape says is one, and every password // inside a URI replaced by a mark naming what was there; and how many were replaced. func redact(line string, known []knownSecret) (string, int) { n := 0 replace := func(s knownSecret) { for _, f := range forms(s.Value) { if c := strings.Count(line, f); c > 0 { line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]") n += c } } } for _, s := range known { replace(s) } for _, s := range shaped(line) { replace(s) } line = uriPassword.ReplaceAllStringFunc(line, func(m string) string { sub := uriPassword.FindStringSubmatch(m) if masked.MatchString(sub[1]) || strings.HasPrefix(sub[1], "[redacted") { return m } n++ return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@" }) return line, n }