Files
mesh-catalog/modules/systemd/cmd/systemd-tools/secrets.go
T
jochen 66106d93ac
mesh/delivery-group group feat/journal-window-on-the-seat delivering: 0 of 2 delivered
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery held for a person: its group feat/journal-window-on-the-seat's composed check did not pass for the heads that merged; a person decides that…
systemd: the journal verb reads a window, and failed is the seat's verb
An incident is read for the minutes it happened in (the operator's direction
2026-10-07): journal takes since, until, priority and a fixed-string match.
Every value is one word of journalctl's argv, held to the forms journalctl
reads, so nothing reaches a shell or is read as an option under sudo. What
the unit printed of a secret is redacted, as docker_logs does, before the
match is applied, so a match cannot find one.

systemd_failed becomes the seat's failed, with an optional scope. Needs the
controller's seat with these verbs (mesh-controller, same branch): an older
controller refuses a claim serving a verb its seat does not promise.
2026-10-07 19:15:20 +02:00

200 lines
6.6 KiB
Go

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>, [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
}