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…
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.
200 lines
6.6 KiB
Go
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
|
|
}
|