Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66106d93ac |
@@ -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=<v> 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: <what it was>]: 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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: <what it was>].",
|
||||
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
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>, [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
|
||||
}
|
||||
@@ -17,13 +17,11 @@
|
||||
"restart",
|
||||
"enable",
|
||||
"disable",
|
||||
"journal"
|
||||
"journal",
|
||||
"failed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"systemd_failed"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user