diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index 2145ee5..ec39a8d 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -29,7 +29,6 @@ import ( "os" "os/exec" "regexp" - "strconv" "strings" "time" ) @@ -288,27 +287,92 @@ func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) { return answer, nil } -// Journal is the last lines of one unit's journal. -func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) { +// Journal is the last lines of one unit's journal that a query keeps, its secrets redacted. +// +// **Every argument is one word of journalctl's argv, never a shell's** (journal.go): the unit is refused +// when it would read as an option, a window bound is given as --since= so a relative "-30min" is its +// value and never a flag, and the rest is validated to the forms journalctl reads before anything runs — +// under sudo, a word read as an option would be root's option. +// +// **A match is a fixed string, applied here to the redacted lines**, not journalctl's --grep, which is a +// pattern and depends on how journalctl was built; and applied after redaction, so a caller cannot find a +// secret by asking which lines hold it. journalctl is then asked for a bounded scan of the window's last +// lines, and the answer says how many were read, so a match that found fewer than asked is not read as +// all there is when the scan was full. +func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]any, error) { if err := unitArg(unit); err != nil { return nil, err } - out, err := m.call(scope, "journalctl", "--no-pager", "-n", strconv.Itoa(lines), "-u", unit, "-o", "short-iso") + q, err := q.valid() if err != nil { return nil, err } - kept := []string{} + read := q.Lines + if q.Match != "" { + read = MatchScan + } + out, err := m.call(scope, "journalctl", q.argv(unit, read)...) + if err != nil { + return nil, err + } + known, envErr := m.unitSecrets(scope, unit) + all := []string{} for _, l := range strings.Split(out, "\n") { if l != "" { - kept = append(kept, l) + all = append(all, l) } } - return map[string]any{"unit": unit, "scope": string(scope), "lines": kept}, nil + scanned := len(all) + kept, redacted := []string{}, 0 + for _, l := range all { + l, n := redact(l, known) + redacted += n + if q.Match != "" && !strings.Contains(l, q.Match) { + continue + } + if len(l) > LongestLine { + l = l[:LongestLine] + "…" + } + kept = append(kept, l) + } + if len(kept) > q.Lines { + kept = kept[len(kept)-q.Lines:] + } + answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)} + for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} { + if v != "" { + answer[k] = v + } + } + if q.Match != "" { + answer["scanned"] = scanned + if scanned >= MatchScan { + answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan) + } + } + if envErr != nil { + answer["redaction"] = "only what a line's shape says is a secret: the unit's environment could not be read (" + envErr.Error() + ")" + } + if redacted > 0 { + answer["redacted"] = redacted + answer["leak"] = "this unit's journal holds secrets, shown as [redacted: ]: rotate each one after the program stops printing it" + } + return answer, nil } -// Failed is every failed unit in both managers. A manager that does not answer is reported as such, -// beside the other's answer — never as "nothing failed". -func (m *Manager) Failed() map[string]any { +// unitSecrets are the values of the unit's own Environment= that must not be answered. Read with +// systemctl show, which needs no escalation; a unit that does not exist has none. +func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) { + out, err := m.call(scope, "systemctl", "show", unit, "--no-pager", "--property=Environment", "--value") + if err != nil { + return nil, err + } + return secretsIn(environment(strings.TrimSpace(out))), nil +} + +// Failed is every failed unit in the managers asked — both when none is named. A manager that does not +// answer is reported as such, beside the other's answer — never as "nothing failed". +func (m *Manager) Failed(scopes ...Scope) map[string]any { in := func(scope Scope) any { units, err := m.Units(scope, "") if err != nil { @@ -322,7 +386,14 @@ func (m *Manager) Failed() map[string]any { } return failed } - return map[string]any{"system": in(System), "user": in(User)} + if len(scopes) == 0 { + scopes = []Scope{System, User} + } + answer := map[string]any{} + for _, s := range scopes { + answer[string(s)] = in(s) + } + return answer } // unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be diff --git a/modules/systemd/cmd/systemd-tools/client_test.go b/modules/systemd/cmd/systemd-tools/client_test.go index 6e5007f..cea7506 100644 --- a/modules/systemd/cmd/systemd-tools/client_test.go +++ b/modules/systemd/cmd/systemd-tools/client_test.go @@ -97,7 +97,7 @@ func TestTheUserScopeIsPlainUserWithTheAccountsRuntimeDirectoryAndBus(t *testing if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") { t.Fatalf("%v", calls[0].env) } - if _, err := m.Journal(User, "watcher.service", 10); err != nil { + if _, err := m.Journal(User, "watcher.service", JournalQuery{Lines: 10}); err != nil { t.Fatal(err) } if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" { @@ -242,7 +242,7 @@ func TestAUnitsNameIsNeverAnOption(t *testing.T) { } } -// The manifest owns the systemd package, claims the seat's eight verbs, and lists exactly the tools served. +// The manifest owns the systemd package, claims the seat's nine verbs, and lists exactly the tools served. func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { diff --git a/modules/systemd/cmd/systemd-tools/journal.go b/modules/systemd/cmd/systemd-tools/journal.go new file mode 100644 index 0000000..30fc3a6 --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/journal.go @@ -0,0 +1,171 @@ +package main + +// What the journal verb may be asked (the operator's direction 2026-10-07, recorded in novox/hq): a time +// window, a priority and a fixed text, beside the unit and how many lines. +// +// **Nothing a caller says reaches a shell, and nothing is read as an option.** journalctl is run with an +// argv of its own words (execRunner), under `sudo -n` for the system journal (issue 255) — so a value that +// journalctl read as an option would be root's option. Every value is therefore held to the forms +// journalctl reads for it and given as `--name=value`, one word: a relative "-30min" is the value of +// --since, never a flag. A value in any other form is refused naming the forms, before anything runs. + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + // MostLines is the most lines one answer carries: well below what the runtime carries back in one reply. + MostLines = 2000 + // DefaultLines is how many when the caller does not say. + DefaultLines = 100 + // MatchScan is how many of the window's last lines a match is looked for in. + MatchScan = 50000 + // LongestLine is where one line is cut, so a single runaway line cannot fill the answer. + LongestLine = 4096 + // LongestMatch is the longest text a match may be. + LongestMatch = 256 +) + +// JournalQuery is what a journal read is narrowed by. +type JournalQuery struct { + Lines int + Since string + Until string + Match string + Priority string +} + +// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words. +var relative = regexp.MustCompile(`^(now|today|yesterday|tomorrow|[+-]?([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y))+|([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y) ?)+ago)$`) + +// localDate is a date, or a date and a time, in the machine's own zone, as journalctl reads it. +var localDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$`) + +// priorities are journalctl's priority names, and the words people use for them. +var priorities = map[string]int{ + "emerg": 0, "emergency": 0, "panic": 0, "alert": 1, "crit": 2, "critical": 2, "err": 3, "error": 3, + "warning": 4, "warn": 4, "notice": 5, "info": 6, "debug": 7, +} + +// when is one bound of the window as journalctl is given it, and the absolute time it names when it is +// one: an RFC 3339 time becomes seconds since the epoch, which every journalctl reads whatever its version. +func when(name, v string, ceil bool) (string, *time.Time, error) { + if t, err := time.Parse(time.RFC3339Nano, v); err == nil { + s := t.Unix() + if ceil && t.Nanosecond() > 0 { + s++ + } + return "@" + strconv.FormatInt(s, 10), &t, nil + } + if relative.MatchString(v) || localDate.MatchString(v) { + return v, nil, nil + } + return "", nil, fmt.Errorf("%s %q: an RFC 3339 time (2026-10-07T09:30:00Z), a time relative to now "+ + "(-30min, -2h, 1h ago, yesterday) or a local date (2026-10-07 09:30)", name, v) +} + +// valid is the query with its defaults applied and every value held to its forms. +func (q JournalQuery) valid() (JournalQuery, error) { + switch { + case q.Lines == 0: + q.Lines = DefaultLines + case q.Lines < 0: + return q, fmt.Errorf("lines %d: at least 1", q.Lines) + case q.Lines > MostLines: + q.Lines = MostLines + } + var since, until *time.Time + var err error + if q.Since != "" { + if _, since, err = when("since", q.Since, false); err != nil { + return q, err + } + } + if q.Until != "" { + if _, until, err = when("until", q.Until, true); err != nil { + return q, err + } + } + if since != nil && until != nil && until.Before(*since) { + return q, fmt.Errorf("the window ends (%s) before it starts (%s)", q.Until, q.Since) + } + if q.Priority != "" { + p := strings.ToLower(q.Priority) + if n, ok := priorities[p]; ok { + q.Priority = strconv.Itoa(n) + } else if len(p) != 1 || p[0] < '0' || p[0] > '7' { + return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority) + } + } + if len(q.Match) > LongestMatch { + return q, fmt.Errorf("a match is at most %d bytes", LongestMatch) + } + if strings.ContainsAny(q.Match, "\n\r\x00") { + return q, fmt.Errorf("a match is one line of text") + } + return q, nil +} + +// argv is journalctl's words for a valid query: each value inside the word of its own option. +func (q JournalQuery) argv(unit string, lines int) []string { + args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit} + if q.Since != "" { + v, _, _ := when("since", q.Since, false) + args = append(args, "--since="+v) + } + if q.Until != "" { + v, _, _ := when("until", q.Until, true) + args = append(args, "--until="+v) + } + if q.Priority != "" { + args = append(args, "--priority="+q.Priority) + } + return append(args, "--lines="+strconv.Itoa(lines)) +} + +// journalQuery is the query a call's arguments say. A number may come as a JSON number or as its text: +// the seat's schema carries every argument as a string. +func journalQuery(a map[string]any) (JournalQuery, error) { + q := JournalQuery{} + switch v := a["lines"].(type) { + case nil: + case float64: + if v != float64(int(v)) { + return q, fmt.Errorf("lines %v: a whole number", v) + } + q.Lines = int(v) + case string: + if strings.TrimSpace(v) != "" { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return q, fmt.Errorf("lines %q: a whole number", v) + } + q.Lines = n + } + default: + return q, fmt.Errorf("lines: a whole number") + } + for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} { + switch v := a[name].(type) { + case nil: + case string: + if name == "match" { + *into = v + } else { + *into = strings.TrimSpace(v) + } + case float64: + if name != "priority" { + return q, fmt.Errorf("%s: text", name) + } + *into = strconv.FormatFloat(v, 'f', -1, 64) + default: + return q, fmt.Errorf("%s: text", name) + } + } + return q, nil +} diff --git a/modules/systemd/cmd/systemd-tools/journal_test.go b/modules/systemd/cmd/systemd-tools/journal_test.go new file mode 100644 index 0000000..2a8e15c --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/journal_test.go @@ -0,0 +1,243 @@ +package main + +// The journal verb's window, priority and match (the operator's direction 2026-10-07): every value one +// word of journalctl's argv, nothing read as an option, the cap kept, a secret never answered — and +// what has failed on the seat. + +import ( + "fmt" + "strings" + "testing" +) + +// journalOf is a manager whose journalctl answers the given lines and whose unit has the given +// Environment=, keeping each call. +func journalOf(lines []string, env string, calls *[]call) *Manager { + return operator(fake(func(c call) Ran { + if contains(c.args, "journalctl") || c.cmd == "journalctl" { + return Ran{Stdout: strings.Join(lines, "\n") + "\n"} + } + if contains(c.args, "--property=Environment") { + return Ran{Stdout: env + "\n"} + } + return Ran{} + }, calls)) +} + +func journalArgs(t *testing.T, calls []call) []string { + t.Helper() + for _, c := range calls { + if c.cmd == "sudo" && len(c.args) > 1 && c.args[1] == "journalctl" { + return c.args[2:] + } + if c.cmd == "journalctl" { + return c.args + } + } + t.Fatalf("journalctl was not run: %+v", calls) + return nil +} + +func TestAWindowAndAPriorityAreEachOneWordOfJournalctl(t *testing.T) { + var calls []call + m := journalOf([]string{"a"}, "", &calls) + _, err := m.Journal(System, "mail.service", JournalQuery{Lines: 50, Since: "-30min", Until: "2026-10-07T10:00:00.5Z", Priority: "warning"}) + if err != nil { + t.Fatal(err) + } + got := strings.Join(journalArgs(t, calls), " ") + want := "--no-pager --output=short-iso --unit=mail.service --since=-30min --until=@1791367201 --priority=4 --lines=50" + if got != want { + t.Fatalf("journalctl was given\n %s\nnot\n %s", got, want) + } + if calls[0].cmd != "sudo" || calls[0].args[0] != "-n" { + t.Fatalf("the system journal was not read escalated: %+v", calls[0]) + } +} + +func TestTheFormsAWindowIsReadIn(t *testing.T) { + for _, ok := range []string{"-30min", "-2h", "+1h", "-1h30min", "2h ago", "30min ago", "yesterday", "now", "today", + "2026-10-07T09:30:00Z", "2026-10-07T09:30:00+02:00", "2026-10-07", "2026-10-07 09:30", "2026-10-07 09:30:15"} { + if _, err := (JournalQuery{Since: ok}).valid(); err != nil { + t.Errorf("%q refused: %v", ok, err) + } + } + for _, bad := range []string{"--user", "-u", "-D/etc", "--directory=/", "-30min --merge", "-30min;id", "$(id)", + "-x", "--", "1h; rm", "2026-10-07T09:30:00", "last week", "-30min\n--merge"} { + if _, err := (JournalQuery{Since: bad}).valid(); err == nil { + t.Errorf("since %q accepted", bad) + } + if _, err := (JournalQuery{Until: bad}).valid(); err == nil { + t.Errorf("until %q accepted", bad) + } + } + if _, err := (JournalQuery{Since: "2026-10-07T10:00:00Z", Until: "2026-10-07T09:00:00Z"}).valid(); err == nil { + t.Error("a window ending before it starts was accepted") + } +} + +func TestPriorityIsANumberOrAName(t *testing.T) { + for in, want := range map[string]string{"0": "0", "7": "7", "err": "3", "ERROR": "3", "warning": "4", "debug": "7", "emerg": "0"} { + q, err := (JournalQuery{Priority: in}).valid() + if err != nil || q.Priority != want { + t.Errorf("%q: %q %v", in, q.Priority, err) + } + } + for _, bad := range []string{"8", "-1", "--user", "3..5", "loud", "33"} { + if _, err := (JournalQuery{Priority: bad}).valid(); err == nil { + t.Errorf("priority %q accepted", bad) + } + } +} + +func TestNothingRunsWhenAValueIsRefused(t *testing.T) { + var calls []call + m := journalOf(nil, "", &calls) + for _, q := range []JournalQuery{{Since: "--merge"}, {Until: "-D/"}, {Priority: "--user"}, {Lines: -1}, + {Match: "a\nb"}, {Match: strings.Repeat("x", LongestMatch+1)}} { + if _, err := m.Journal(System, "x.service", q); err == nil { + t.Errorf("%+v accepted", q) + } + } + if _, err := m.Journal(System, "--merge", JournalQuery{}); err == nil { + t.Error("an option was accepted as a unit") + } + if len(calls) != 0 { + t.Fatalf("something ran: %+v", calls) + } +} + +func TestTheCapIsKeptAndTheDefaultIsAHundred(t *testing.T) { + for in, want := range map[int]int{0: DefaultLines, 1: 1, 2000: 2000, 2001: 2000, 1 << 30: 2000} { + q, err := (JournalQuery{Lines: in}).valid() + if err != nil || q.Lines != want { + t.Errorf("%d: %d %v", in, q.Lines, err) + } + } + // What the seat's schema carries is text, and an agent may send a number: both read the same. + for _, a := range []map[string]any{{"lines": "250"}, {"lines": float64(250)}} { + q, err := journalQuery(a) + if err != nil || q.Lines != 250 { + t.Errorf("%v: %+v %v", a, q, err) + } + } + for _, a := range []map[string]any{{"lines": "many"}, {"lines": 2.5}, {"since": float64(3)}, {"match": []any{"x"}}} { + if _, err := journalQuery(a); err == nil { + t.Errorf("%v accepted", a) + } + } +} + +func TestAMatchIsAFixedStringOverTheWindowsLastLines(t *testing.T) { + var calls []call + lines := []string{"one (a.b)", "two a.b", "three axb", "four (a.b)"} + r, err := journalOf(lines, "", &calls).Journal(System, "x.service", JournalQuery{Lines: 1, Match: "(a.b)"}) + if err != nil { + t.Fatal(err) + } + if got := r["lines"].([]string); len(got) != 1 || got[0] != "four (a.b)" { + t.Fatalf("%v", got) + } + if r["scanned"] != 4 || r["count"] != 1 || r["match"] != "(a.b)" { + t.Fatalf("%v", r) + } + if a := journalArgs(t, calls); a[len(a)-1] != fmt.Sprintf("--lines=%d", MatchScan) { + t.Fatalf("a match was not looked for over a scan: %v", a) + } + for _, a := range journalArgs(t, calls) { + if strings.Contains(a, "a.b") || strings.HasPrefix(a, "--grep") { + t.Fatalf("the match reached journalctl: %v", a) + } + } +} + +func TestASecretTheUnitPrintedIsNeverAnsweredNorFoundByAMatch(t *testing.T) { + env := `HOME=/var/lib/x "DB_PASSWORD=hunter2hunter2" DATABASE_URL=postgres://app:s3cr3tpw@db/app PORT=5432` + lines := []string{ + "starting with password hunter2hunter2", + "connecting to postgres://app:s3cr3tpw@db/app", + "proxy at https://u:otherpassword@example.test/", + "ran: tool --password=flagsecret1 --token tokensecret2", + "API_TOKEN=abcdefghij set", + "nothing secret here", + } + r, err := journalOf(lines, env, nil).Journal(System, "x.service", JournalQuery{}) + if err != nil { + t.Fatal(err) + } + all := strings.Join(r["lines"].([]string), "\n") + for _, secret := range []string{"hunter2hunter2", "s3cr3tpw", "otherpassword", "flagsecret1", "tokensecret2", "abcdefghij"} { + if strings.Contains(all, secret) { + t.Errorf("%s was answered:\n%s", secret, all) + } + } + if !strings.Contains(all, "[redacted: DB_PASSWORD]") || !strings.Contains(all, "nothing secret here") { + t.Fatalf("%s", all) + } + if r["redacted"] == nil || r["leak"] == nil { + t.Fatalf("the redaction was not said: %v", r) + } + found, _ := journalOf(lines, env, nil).Journal(System, "x.service", JournalQuery{Match: "hunter2"}) + if found["count"] != 0 { + t.Fatalf("a match found a secret: %v", found) + } +} + +func TestAnUnreadableEnvironmentStillRedactsByShapeAndSaysSo(t *testing.T) { + m := operator(fake(func(c call) Ran { + if contains(c.args, "--property=Environment") { + return Ran{Status: 1, Stderr: "Failed to get properties: Access denied\n"} + } + return Ran{Stdout: "postgres://app:s3cr3tpw@db/app\n"} + }, nil)) + r, err := m.Journal(System, "x.service", JournalQuery{}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(r["lines"].([]string), ""), "s3cr3tpw") || r["redaction"] == nil { + t.Fatalf("%v", r) + } +} + +func TestALongLineIsCut(t *testing.T) { + r, _ := journalOf([]string{strings.Repeat("y", LongestLine+10)}, "", nil).Journal(System, "x.service", JournalQuery{}) + if l := r["lines"].([]string)[0]; len(l) > LongestLine+len("…") { + t.Fatalf("a line of %d bytes", len(l)) + } +} + +func TestTheEnvironmentPropertyIsReadAsWords(t *testing.T) { + got := environment(`A=1 "B=two words" 'C=x\'y' D=`) + if strings.Join(got, "|") != "A=1|B=two words|C=x'y|D=" { + t.Fatalf("%q", got) + } +} + +func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) { + var calls []call + m := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls)) + var failed func(map[string]any) (any, error) + for _, tool := range tools(m) { + if tool.Name == seat+".failed" { + failed = tool.Run + } + if tool.Name == "systemd_failed" { + t.Fatal("the module still serves its own systemd_failed beside the seat's verb") + } + } + if failed == nil { + t.Fatal("the seat's failed is not served") + } + both, err := failed(map[string]any{}) + if err != nil || len(both.(map[string]any)) != 2 { + t.Fatalf("%v %v", both, err) + } + calls = nil + one, err := failed(map[string]any{"scope": "system"}) + if err != nil || len(one.(map[string]any)) != 1 || len(calls) != 1 || contains(calls[0].args, "--user") { + t.Fatalf("%v %v %+v", one, err, calls) + } + if _, err := failed(map[string]any{"scope": "everything"}); err == nil { + t.Fatal("a scope that is none was accepted") + } +} diff --git a/modules/systemd/cmd/systemd-tools/main.go b/modules/systemd/cmd/systemd-tools/main.go index 237df05..79e4fce 100644 --- a/modules/systemd/cmd/systemd-tools/main.go +++ b/modules/systemd/cmd/systemd-tools/main.go @@ -1,5 +1,5 @@ -// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in both scopes, -// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). The node +// systemd's tools: the node-service-manager seat's nine verbs — the units on this machine in both scopes, +// read and acted on by name, their journal, and what has failed (novox/hq ADR 0177). The node // tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193); // it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with // sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers @@ -98,9 +98,16 @@ func tools(m *Manager) []stdio.Tool { act("enable", "Make one unit start at boot (or at the account's login, in user scope)."), act("disable", "Stop one unit starting at boot (or at login, in user scope)."), {Name: seat + ".journal", - Description: "The last lines of one unit's journal (at most 2000) — a system service's included: the read is escalated, so it is the service's own lines and not only the operator account's.", + Description: "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority " + + "and to lines holding a text when asked — a system service's included: the read is escalated, so it is the " + + "service's own lines and not only the operator account's. A secret the unit printed is shown as " + + "[redacted: ].", Input: map[string]any{"scope": scopeArg, "unit": unitArgS, - "lines": map[string]any{"type": "number", "description": "how many lines from the end (default 100, at most 2000)"}}, + "lines": str("how many lines from the end of what matches (default 100, at most 2000)"), + "since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"), + "until": str("the window's end, in the same forms (optional; now when absent)"), + "match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"), + "priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")}, Run: func(a map[string]any) (any, error) { scope, err := scopeOf(a) if err != nil { @@ -110,16 +117,27 @@ func tools(m *Manager) []stdio.Tool { if err != nil { return nil, err } - // Bounded so the answer stays well below what the runtime carries back in one reply. - n := 100 - if v, ok := a["lines"].(float64); ok && v >= 1 { - n = min(int(v), 2000) + q, err := journalQuery(a) + if err != nil { + return nil, err } - return m.Journal(scope, unit, n) + return m.Journal(scope, unit, q) }}, - {Name: "systemd_failed", + // Was the module's own systemd_failed; on the seat since the operator's direction of 2026-10-07, so + // whatever holds the role answers it and every machine is asked the same way. + {Name: seat + ".failed", Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.", - Run: func(map[string]any) (any, error) { return m.Failed(), nil }}, + Input: map[string]any{"scope": str(`"system" or "user": only that manager (both when absent)`)}, + Run: func(a map[string]any) (any, error) { + if s, _ := a["scope"].(string); s == "" { + return m.Failed(), nil + } + scope, err := scopeOf(a) + if err != nil { + return nil, err + } + return m.Failed(scope), nil + }}, } } diff --git a/modules/systemd/cmd/systemd-tools/secrets.go b/modules/systemd/cmd/systemd-tools/secrets.go new file mode 100644 index 0000000..3763e09 --- /dev/null +++ b/modules/systemd/cmd/systemd-tools/secrets.go @@ -0,0 +1,199 @@ +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 +} diff --git a/modules/systemd/module.json b/modules/systemd/module.json index 3e9a0e9..e7b879a 100644 --- a/modules/systemd/module.json +++ b/modules/systemd/module.json @@ -17,13 +17,11 @@ "restart", "enable", "disable", - "journal" + "journal", + "failed" ] } ], - "tools": [ - "systemd_failed" - ], "build": { "artifacts": [ {