From 994cab533bbd988f7e951040cbb6013d1a3441dc Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 14:32:22 +0200 Subject: [PATCH] mesh-reads: read a node's files and accounts as an account that holds no secret (hq ADR 0310, issue 418) Reading a file, searching one, listing a directory, the owner and mode of each component of a path and looking up an account went through the login shell's execute as the operator's account, which sees the operator's keys and the agent's credentials. This module holds node-reads on every node and runs as mesh-reads (no login, no group beside its own). It refuses the node's secret places by name, judging the file actually opened rather than the name asked, so a flipped link reaches nothing refused; withholds any line shaped like a secret through mesh-sdk's outward rule and every line of a BEGIN/END block; and bounds every answer. Replaces mesh-catalog #244 (reviews 55), whose first commit carried a built binary. --- .gitignore | 2 + modules/mesh-reads/README.md | 63 + modules/mesh-reads/cmd/mesh-reads/main.go | 204 ++++ .../cmd/mesh-reads/manifest_test.go | 59 + modules/mesh-reads/cmd/mesh-reads/reads.go | 1043 +++++++++++++++++ .../mesh-reads/cmd/mesh-reads/reads_test.go | 562 +++++++++ modules/mesh-reads/cmd/mesh-reads/refuse.go | 129 ++ modules/mesh-reads/go.mod | 5 + modules/mesh-reads/go.sum | 2 + modules/mesh-reads/module.json | 54 + 10 files changed, 2123 insertions(+) create mode 100644 modules/mesh-reads/README.md create mode 100644 modules/mesh-reads/cmd/mesh-reads/main.go create mode 100644 modules/mesh-reads/cmd/mesh-reads/manifest_test.go create mode 100644 modules/mesh-reads/cmd/mesh-reads/reads.go create mode 100644 modules/mesh-reads/cmd/mesh-reads/reads_test.go create mode 100644 modules/mesh-reads/cmd/mesh-reads/refuse.go create mode 100644 modules/mesh-reads/go.mod create mode 100644 modules/mesh-reads/go.sum create mode 100644 modules/mesh-reads/module.json diff --git a/.gitignore b/.gitignore index 18c2b03c..3a9d9d08 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ modules/mesh-issues/cmd/mesh-issues/mesh-issues modules/mounts/cmd/mounts/mounts modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge modules/mesh-knowledge/mesh-knowledge +modules/mesh-reads/mesh-reads +modules/mesh-reads/cmd/mesh-reads/mesh-reads diff --git a/modules/mesh-reads/README.md b/modules/mesh-reads/README.md new file mode 100644 index 00000000..2981bdc3 --- /dev/null +++ b/modules/mesh-reads/README.md @@ -0,0 +1,63 @@ +# mesh-reads + +A node's files and accounts, read through the mesh (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 +WP8; issue 418). It holds the node seat `node-reads` on every node, the control-node included, so reading a +file, searching one, listing a directory, the owner and mode of each component of a path and looking up an +account or a group never go through the login shell's `execute`, and an approval is asked for an act, never +for a read. + +## Who it reads as + +The account `mesh-reads`, which this module makes on every node (`runs-as`, ADR 0259 §8): no login shell, a +home of its own under `/var/lib`, and no group beside its own. The tool runner serves it in a runtime of its +own, on its own bus credential, never the machine's runtime, which runs as the operator's account. So the +node's file modes bound what it sees: a file only the operator, root or a module's own account may read is +refused by its mode, and the answer says so. It refuses every read when it finds itself running as root. + +## What it refuses by name + +Before any read, whatever the file modes would allow (ADR 0310 §5): + +- **The places the mesh keeps a secret on this node**, which the controller composes from the manifests + and gives it as `MESH_SECRET_PLACES`: the directory of every module's own secret (its bus credential + among them), every credential file and grant directory, the vault's directories, and where the + operator-sealed secrets are kept. Without that list the module does not start. +- **The known credential files of a machine's programs** (`refuse.go`): an account's `.ssh`, `.gnupg`, + `.aws`, `.kube`; the agent's `.credentials.json`, `.netrc`, `.pgpass`, `.git-credentials`; private key + names (`id_ed25519`, `*.key`, `ssh_host_*_key`); `/etc/shadow`; `/etc/ssl/private`, `/etc/wireguard` + and the network manager's connections. + +- **Histories and profiles**: shell and program histories (`.zsh_history`, `.bash_history`, `.psql_history`, + `.viminfo` …), browser and mail profiles (`.mozilla`, `.config/google-chrome`, `.config/chromium` …), a + process's `environ`, `mem`, `fd`, `map_files`, `root` and `cwd` under `/proc`, and `/etc/rancher` and + `/etc/kubernetes/admin.conf`. + +**What was opened is judged, not the name.** The type is checked before the open, so a device or a fifo is +never opened; the file is opened non-blocking (in a search without following a link), checked again on its +descriptor, and the path the kernel gives for that descriptor is judged by name like any path asked. A link +flipped between the check and the open therefore reaches nothing a name refuses, and a deleted file is refused. +**Limit:** a hard link to a secret file under another name is not seen by the name rule; the file's own mode, +and the kernel's protected hard links, are what bound it. `stat` and `readlink` say a secret place's owner +and mode, and mark it, because those are not its content; `list`, `read` and `search` refuse it. + +## The content rule + +Every answer passes the content rule's secret class, from `mesh-sdk`'s `outward` package (ADR 0310 §4): a +line of a file or a matching line shaped like a secret is withheld and its number said, as is a link target +or an account's words. Every line of a `BEGIN … END` block — a private key's or a certificate's — is withheld whole, because one +base64 line of a key can pass the rule alone. A path and an entry's name are not judged: they are what the caller named or what the +file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a +token. + +## Its verbs and their bounds + +| verb | replaces | bounds | +|---|---|---| +| `stat` | `stat`, `ls -ld`, `namei -l`, `findmnt -T` | 200 paths, 64 components each; links followed only with `follow` | +| `readlink` | `readlink`, `readlink -f`, `realpath` | one path | +| `list` | `ls`, `ls -la` | 2000 entries, cut and said | +| `read` | `cat`, `head`, `tail`, `less` | 64 KiB, or up to 256 KiB when asked; cut at a line and said; text only | +| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds (checked inside a file too); the named path's file system only, the others named; the bound met is said | +| `account` | `getent passwd`, `getent group`, `id`, `groups` | 2000 accounts and groups; never a password field | + +No answer is larger than 1 MiB. Nothing here writes a file. diff --git a/modules/mesh-reads/cmd/mesh-reads/main.go b/modules/mesh-reads/cmd/mesh-reads/main.go new file mode 100644 index 00000000..cb20f6f5 --- /dev/null +++ b/modules/mesh-reads/cmd/mesh-reads/main.go @@ -0,0 +1,204 @@ +// mesh-reads (novox/hq ADR 0310, to-be 41 WP8): the holder of node-reads. A node's files and accounts read +// through the mesh — the owner and mode of each component of a path, a link, a directory, a text file, a search +// by fixed string, an account or a group — so these reads never go through the login shell's execute. +// +// It runs as an account of its own, mesh-reads, served by the tool runner in a runtime of its own (ADR 0259 +// §8): in no group that holds a secret, with no login shell and no home of the operator's, so the node's file +// modes bound what it sees. On top, it refuses by name the places a secret is kept (refuse.go) and withholds +// any piece of an answer shaped like a secret (mesh-sdk's outward). Read-only: nothing here writes a file. +// +// stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds. +const Seat = "node-reads" + +// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0310 §5). +const PlacesWord = "MESH_SECRET_PLACES" + +func main() { + places, err := placesFrom(os.Getenv(PlacesWord)) + if err != nil { + fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err) + os.Exit(1) + } + if err := stdio.Serve("", tools(newReader(places))); err != nil { + fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err) + os.Exit(1) + } +} + +// placesFrom reads the controller's list. Absent is refused: without it, a module's own-secret directory +// would be read wherever the account's modes allow, and a refusal the decision promises would be silent. +func placesFrom(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("%s is not given, so this node's secret places are unknown and nothing would be "+ + "refused by them (novox/hq ADR 0310 §5): a controller that knows node-reads composes it", PlacesWord) + } + var places []string + if err := json.Unmarshal([]byte(raw), &places); err != nil { + return nil, fmt.Errorf("%s is not a list of paths: %v", PlacesWord, err) + } + return places, nil +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func arg(a map[string]any, k string) string { + switch v := a[k].(type) { + case string: + return strings.TrimSpace(v) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + } + return "" +} + +func number(a map[string]any, k string) (int, error) { + s := arg(a, k) + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("%s is a whole number, and %q was given", k, s) + } + return n, nil +} + +// pathsArg is the paths given, one per line — or, from a caller that sends a list, the list. +func pathsArg(a map[string]any) []string { + var raw []string + switch v := a["paths"].(type) { + case string: + raw = strings.Split(v, "\n") + case []any: + for _, p := range v { + if s, ok := p.(string); ok { + raw = append(raw, s) + } + } + } + var out []string + for _, p := range raw { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// bounded is the last bound (novox/hq ADR 0310): whatever a verb made, no answer is larger than MaxAnswer. +func bounded(answer any, err error) (any, error) { + if err != nil { + return nil, err + } + body, err := json.Marshal(answer) + if err != nil { + return nil, err + } + if len(body) > MaxAnswer { + return nil, fmt.Errorf("the answer is %d bytes, and an answer of node-reads is at most %d: ask for less", len(body), MaxAnswer) + } + return answer, nil +} + +// verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's subject. +// The descriptions are the controller's (internal/catalogue/reads.go), said again here for a caller of the +// module's own address. +func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, + Run: func(a map[string]any) (any, error) { return bounded(run(a)) }} +} + +func object(props map[string]any, required ...string) map[string]any { + if required == nil { + required = []string{} + } + return map[string]any{"type": "object", "properties": props, "required": required} +} + +func tools(r Reader) []stdio.Tool { + return []stdio.Tool{ + verb("stat", "Each component of one path or several, from / down: its type, owner and group (name and "+ + "number), mode, whether it is a link and to what, and whether it is a mount point and of which file "+ + "system. A link is not followed unless follow is \"true\". Read as the account mesh-reads, which holds no "+ + "secret: a component it may not reach says so. At most 200 paths in one call.", + object(map[string]any{"paths": str("one absolute path, or several separated by line breaks"), + "follow": str("\"true\": also say where the path leads once every link is followed")}, "paths"), + func(a map[string]any) (any, error) { return r.Stat(pathsArg(a), arg(a, "follow") == "true") }), + verb("readlink", "What one link points to, as written, and where the path leads once every link is "+ + "followed, and whether that exists.", + object(map[string]any{"path": str("an absolute path")}, "path"), + func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }), + verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+ + "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+ + "refused by name.", + object(map[string]any{"path": str("an absolute path to a directory")}, "path"), + func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }), + verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+ + "is asked), cut at a line and said so. A negative from counts from the end, as tail does. A binary file "+ + "is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped "+ + "like a secret is withheld and its number said. /proc and /sys are read as far as mesh-reads may.", + object(map[string]any{"path": str("an absolute path to a file"), + "from": str("the first line to answer, counted from 1; negative: that many lines from the end (optional)"), + "lines": str("how many lines to answer (optional)"), + "bytes": str("the most bytes to answer, up to 262144 (optional, 65536 unless given)")}, "path"), + func(a map[string]any) (any, error) { + var ask ReadAsk + var err error + if ask.From, err = number(a, "from"); err != nil { + return nil, err + } + if ask.Lines, err = number(a, "lines"); err != nil { + return nil, err + } + if ask.Bytes, err = number(a, "bytes"); err != nil { + return nil, err + } + return r.Read(arg(a, "path"), ask) + }), + verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+ + "line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+ + "deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds, on the file system of the path "+ + "named; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+ + "a secret is withheld. The answer says which bound it met.", + object(map[string]any{"text": str("the fixed string to find"), + "path": str("an absolute path to a file or a directory"), + "depth": str("how many directories deep, up to 8 (optional)"), + "matches": str("the most matches to answer, up to 500 (optional)"), + "case": str("\"false\": match whatever the case (optional)")}, "text", "path"), + func(a map[string]any) (any, error) { + text, _ := a["text"].(string) // as given: a space at either end is part of what is found + ask := SearchAsk{Text: text, Path: arg(a, "path"), CaseInsensitive: arg(a, "case") == "false"} + var err error + if ask.Depth, err = number(a, "depth"); err != nil { + return nil, err + } + if ask.Matches, err = number(a, "matches"); err != nil { + return nil, err + } + return r.Search(ask) + }), + verb("account", "One account or group by name or number, as this node's account files say: an account's "+ + "number, group, home, shell and every group it is in; a group's number and members. With neither, every "+ + "account and group. Never a password field.", + object(map[string]any{"name": str("an account's name or number (optional)"), + "group": str("a group's name or number (optional)")}), + func(a map[string]any) (any, error) { return r.Account(arg(a, "name"), arg(a, "group")) }), + } +} diff --git a/modules/mesh-reads/cmd/mesh-reads/manifest_test.go b/modules/mesh-reads/cmd/mesh-reads/manifest_test.go new file mode 100644 index 00000000..2422aef5 --- /dev/null +++ b/modules/mesh-reads/cmd/mesh-reads/manifest_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// ADR 0310 §3: the account the reads run as is the module's own, made by the mesh, with no login shell, no +// home of the operator's and no group beside its own, and the module runs as it. +func TestTheAccountHoldsNoSecretsGroupAndNoLogin(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + SecretsOwner string `json:"secrets-owner"` + Claims []map[string]any `json:"claims"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.RunsAs != "mesh-reads" || m.SecretsOwner != "mesh-reads" { + t.Errorf("runs as %q, secrets owned by %q", m.RunsAs, m.SecretsOwner) + } + if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat { + t.Errorf("claims %v", m.Claims) + } + found := false + for _, r := range m.Resources { + if r["type"] != "user" { + continue + } + found = true + if r["name"] != m.RunsAs { + t.Errorf("a user other than its own: %v", r) + } + if r["shell"] != "/usr/bin/nologin" { + t.Errorf("the account has a login shell: %v", r["shell"]) + } + if home, _ := r["home"].(string); !strings.HasPrefix(home, "/var/lib/") { + t.Errorf("the account's home is %q, not one of the mesh's", home) + } + if _, grouped := r["groups"]; grouped { + t.Errorf("the account is put in groups: %v", r["groups"]) + } + } + if !found { + t.Error("the module makes no account of its own") + } + for _, r := range m.Resources { + if r["type"] != "user" && r["type"] != "directory" { + t.Errorf("a read-only holder declares a %v: %v", r["type"], r) + } + } +} diff --git a/modules/mesh-reads/cmd/mesh-reads/reads.go b/modules/mesh-reads/cmd/mesh-reads/reads.go new file mode 100644 index 00000000..762a5de8 --- /dev/null +++ b/modules/mesh-reads/cmd/mesh-reads/reads.go @@ -0,0 +1,1043 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// The bounds (novox/hq ADR 0310, Consequences: set by the build and stated in each verb's description). Every +// answer stays under MaxAnswer, which the tools check once more after an answer is made. +const ( + MaxAnswer = 1 << 20 // no answer of this module is larger, whatever the bounds below allow + MaxPaths = 200 // paths one stat answers + MaxComponents = 64 // components of one path + MaxEntries = 2000 // entries one list answers + DefaultReadSize = 64 << 10 // bytes a read answers unless more is asked + MaxReadSize = 256 << 10 // bytes a read answers at most + MaxScan = 64 << 20 // bytes a read passes over to reach the lines asked + DefaultMatches = 100 + MaxMatches = 500 + DefaultDepth = 4 + MaxDepth = 8 + MaxFiles = 5000 // files one search opens + MaxFileScan = 4 << 20 // bytes one search reads of one file + MaxLine = 300 // characters of one matching line answered + SearchTime = 20 * time.Second + MaxAccounts = 2000 +) + +// Reader reads one node's files and accounts as the account it runs as. +type Reader struct { + Refuser + // Passwd, Group and MountInfo are the files accounts, groups and mount points are read from. + Passwd, Group, MountInfo string + // Euid is the account this runs as; never root (ADR 0310 §3). + Euid func() int + // Now is the clock a search is bounded by. + Now func() time.Time +} + +func newReader(places []string) Reader { + return Reader{Refuser: Refuser{Places: places}, Passwd: "/etc/passwd", Group: "/etc/group", + MountInfo: "/proc/self/mountinfo", Euid: os.Geteuid, Now: time.Now} +} + +// asItsOwn refuses every read when this runs as root: what it may see is bounded by its account's file +// modes, and root's are none (novox/hq ADR 0310 §3). Loud, never a silent widening. +func (r Reader) asItsOwn() error { + if r.Euid() == 0 { + return errors.New("mesh-reads runs as root here, and its reads are bounded by an account of its own that " + + "holds no secret (novox/hq ADR 0310 §3): it answers nothing until it runs as mesh-reads") + } + return nil +} + +func absolute(path string) (string, error) { + path = strings.TrimSpace(path) + if !strings.HasPrefix(path, "/") { + return "", fmt.Errorf("%q is not an absolute path: name it from /", path) + } + return filepath.Clean(path), nil +} + +// why turns a file error into the words an answer says: what stopped the read, never a Go error's text. +func why(err error) string { + switch { + case errors.Is(err, fs.ErrNotExist): + return "it does not exist" + case errors.Is(err, fs.ErrPermission): + return "the account mesh-reads may not reach it (its file modes refuse it)" + case errors.Is(err, syscall.ELOOP): + return "its links loop" + case errors.Is(err, syscall.ENOTDIR): + return "a component of it is not a directory" + } + var pe *fs.PathError + if errors.As(err, &pe) { + return pe.Err.Error() + } + return err.Error() +} + +// errRefusedByName marks a refusal by name, which a search names rather than counts as unreadable. +var errRefusedByName = errors.New("refused by name") + +// openJudged opens a path and judges what was opened, not its name (the review of #244: a link flipped between +// the name check and the open read a secret place 3744 times in 20000): the type is checked before the open, so +// a device or a fifo is never opened; the descriptor is opened non-blocking, without a controlling terminal, and +// in a search without following a link; then it is checked again by fstat, and the path the kernel says it is +// (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is +// refused. +func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) { + stat := os.Stat + if nofollow { + stat = os.Lstat + } + before, err := stat(path) + if err != nil { + return nil, nil, fmt.Errorf("%s cannot be read: %s", path, why(err)) + } + if err := wantType(path, before, dir); err != nil { + return nil, nil, err + } + flags := os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOCTTY | syscall.O_CLOEXEC + if nofollow { + flags |= syscall.O_NOFOLLOW + } + if dir { + flags |= syscall.O_DIRECTORY + } + f, err := os.OpenFile(path, flags, 0) + if err != nil { + return nil, nil, fmt.Errorf("%s cannot be read: %s", path, why(err)) + } + info, err := f.Stat() + if err == nil { + err = wantType(path, info, dir) + } + if err != nil { + f.Close() + return nil, nil, err + } + real, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", f.Fd())) + if err != nil || !strings.HasPrefix(real, "/") || strings.HasSuffix(real, " (deleted)") { + f.Close() + return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+ + "judged by name (novox/hq ADR 0310 §5)", path) + } + if why := r.Refused(real); why != "" { + f.Close() + return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0310 §5): %w", path, real, why, errRefusedByName) + } + return f, info, nil +} + +func wantType(path string, info fs.FileInfo, dir bool) error { + switch { + case dir && !info.IsDir(): + return fmt.Errorf("%s is a %s, not a directory", path, typeOf(info.Mode())) + case !dir && !info.Mode().IsRegular(): + return fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode())) + } + return nil +} + +// deviceOf is the file system a path is on. +func deviceOf(path string) (uint64, bool) { + info, err := os.Lstat(path) + if err != nil { + return 0, false + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return uint64(st.Dev), true +} + +// ---- stat ---- + +// Component is one component of a path as lstat sees it. +type Component struct { + Path string `json:"path"` + Type string `json:"type,omitempty"` + Owner string `json:"owner,omitempty"` + UID *int `json:"uid,omitempty"` + Group string `json:"group,omitempty"` + GID *int `json:"gid,omitempty"` + Mode string `json:"mode,omitempty"` // octal, with the setuid, setgid and sticky bits + Perms string `json:"perms,omitempty"` // as ls shows them + Size *int64 `json:"size,omitempty"` + Link string `json:"link,omitempty"` // what a link points to, as written + Mount *Mount `json:"mount,omitempty"` + Secret string `json:"secret-place,omitempty"` // why a read under it is refused + Unreachd string `json:"unreachable,omitempty"` +} + +// Mount is a mount point: its file system and source. +type Mount struct { + FS string `json:"fs"` + Source string `json:"source"` +} + +// PathStat is one path's components, from / down, and where it leads once every link is followed. +type PathStat struct { + Path string `json:"path"` + Components []Component `json:"components"` + LeadsTo string `json:"leads-to,omitempty"` + Leads *Component `json:"there,omitempty"` + Stopped string `json:"stopped,omitempty"` +} + +// StatAnswer is what stat answers. +type StatAnswer struct { + Paths []PathStat `json:"paths"` + Withheld []Withheld `json:"withheld,omitempty"` +} + +func (r Reader) Stat(paths []string, follow bool) (StatAnswer, error) { + if err := r.asItsOwn(); err != nil { + return StatAnswer{}, err + } + if len(paths) == 0 { + return StatAnswer{}, errors.New("name a path to stat") + } + if len(paths) > MaxPaths { + return StatAnswer{}, fmt.Errorf("%d paths asked, and one stat answers at most %d: ask in parts", len(paths), MaxPaths) + } + names := r.names() + mounts := r.mounts() + var out StatAnswer + for _, raw := range paths { + path, err := absolute(raw) + if err != nil { + return StatAnswer{}, err + } + ps := PathStat{Path: path} + walk := []string{"/"} + acc := "" + for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if part == "" { + continue + } + acc += "/" + part + walk = append(walk, acc) + } + if len(walk) > MaxComponents { + return StatAnswer{}, fmt.Errorf("%s has %d components, and a stat answers at most %d", path, len(walk), MaxComponents) + } + for _, p := range walk { + c := r.component(p, names, mounts) + ps.Components = append(ps.Components, c) + if c.Unreachd != "" { + ps.Stopped = c.Path + ": " + c.Unreachd + break + } + } + if follow && ps.Stopped == "" { + if leads, err := filepath.EvalSymlinks(path); err != nil { + ps.Stopped = "following its links: " + why(err) + } else { + ps.LeadsTo = leads + if leads != path { + c := r.component(leads, names, mounts) + ps.Leads = &c + } + } + } + out.Paths = append(out.Paths, ps) + } + out.Withheld = cleanStat(&out) + return out, nil +} + +func (r Reader) component(p string, names accounts, mounts map[string]Mount) Component { + c := Component{Path: p} + info, err := os.Lstat(p) + if err != nil { + c.Unreachd = why(err) + return c + } + c.Type = typeOf(info.Mode()) + c.Perms = info.Mode().String() + if st, ok := info.Sys().(*syscall.Stat_t); ok { + uid, gid := int(st.Uid), int(st.Gid) + c.UID, c.GID = &uid, &gid + c.Owner, c.Group = names.user(uid), names.group(gid) + c.Mode = fmt.Sprintf("%04o", st.Mode&07777) + } + if info.Mode().IsRegular() { + size := info.Size() + c.Size = &size + } + if info.Mode()&fs.ModeSymlink != 0 { + if target, err := os.Readlink(p); err == nil { + c.Link = target + } + } + if m, ok := mounts[p]; ok { + c.Mount = &m + } + if why := r.Refused(p); why != "" { + c.Secret = why + } + return c +} + +func typeOf(m fs.FileMode) string { + switch { + case m.IsDir(): + return "directory" + case m&fs.ModeSymlink != 0: + return "link" + case m.IsRegular(): + return "file" + case m&fs.ModeNamedPipe != 0: + return "fifo" + case m&fs.ModeSocket != 0: + return "socket" + case m&fs.ModeCharDevice != 0: + return "character device" + case m&fs.ModeDevice != 0: + return "block device" + } + return "other" +} + +func cleanStat(a *StatAnswer) []Withheld { + var w []Withheld + cleanC := func(c *Component) { + for _, f := range []*string{&c.Link} { + if s, what := clean(*f); what != "" { + w = append(w, Withheld{At: "a link's target", Class: what}) + *f = s + } + } + if c.Mount != nil { + if s, what := clean(c.Mount.Source); what != "" { + w = append(w, Withheld{At: "a mount's source", Class: what}) + c.Mount.Source = s + } + } + } + for i := range a.Paths { + p := &a.Paths[i] + for j := range p.Components { + cleanC(&p.Components[j]) + } + if p.Leads != nil { + cleanC(p.Leads) + } + } + return w +} + +// ---- readlink ---- + +// LinkAnswer is what readlink answers. +type LinkAnswer struct { + Path string `json:"path"` + Link string `json:"link,omitempty"` // as written; empty when the path is not a link + IsLink bool `json:"is-link"` + LeadsTo string `json:"leads-to,omitempty"` + Exists bool `json:"exists"` + Stopped string `json:"stopped,omitempty"` + Withheld []Withheld `json:"withheld,omitempty"` +} + +func (r Reader) Readlink(raw string) (LinkAnswer, error) { + if err := r.asItsOwn(); err != nil { + return LinkAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return LinkAnswer{}, err + } + a := LinkAnswer{Path: path} + info, err := os.Lstat(path) + if err != nil { + a.Stopped = why(err) + return a, nil + } + if info.Mode()&fs.ModeSymlink != 0 { + a.IsLink = true + if a.Link, err = os.Readlink(path); err != nil { + a.Stopped = why(err) + } + } + if leads, err := filepath.EvalSymlinks(path); err == nil { + a.LeadsTo, a.Exists = leads, true + } else if a.Stopped == "" { + a.Stopped = "following its links: " + why(err) + } + for _, f := range []*string{&a.Link} { + if s, what := clean(*f); what != "" { + a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what}) + *f = s + } + } + return a, nil +} + +// ---- list ---- + +// Entry is one entry of a directory. +type Entry struct { + Name string `json:"name"` + Type string `json:"type"` + Owner string `json:"owner,omitempty"` + Group string `json:"group,omitempty"` + Mode string `json:"mode,omitempty"` + Size *int64 `json:"size,omitempty"` + Link string `json:"link,omitempty"` +} + +// ListAnswer is what list answers. +type ListAnswer struct { + Path string `json:"path"` + Entries []Entry `json:"entries"` + Count int `json:"count"` + Cut string `json:"cut,omitempty"` + Withheld []Withheld `json:"withheld,omitempty"` +} + +// refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a +// link to a secret place is refused as the place is. +func (r Reader) refusedRead(path string) error { + if why := r.Refused(path); why != "" { + return fmt.Errorf("%s is refused by name: %s (novox/hq ADR 0310 §5)", path, why) + } + if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path { + if why := r.Refused(leads); why != "" { + return fmt.Errorf("%s leads to %s, which is refused by name: %s (novox/hq ADR 0310 §5)", path, leads, why) + } + } + return nil +} + +func (r Reader) List(raw string) (ListAnswer, error) { + if err := r.asItsOwn(); err != nil { + return ListAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ListAnswer{}, err + } + if err := r.refusedRead(path); err != nil { + return ListAnswer{}, err + } + dir, _, err := r.openJudged(path, false, true) + if err != nil { + return ListAnswer{}, err + } + entries, err := dir.ReadDir(-1) + dir.Close() + if err != nil { + return ListAnswer{}, fmt.Errorf("%s cannot be listed: %s", path, why(err)) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + names := r.names() + a := ListAnswer{Path: path, Count: len(entries), Entries: []Entry{}} + if len(entries) > MaxEntries { + a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, len(entries)) + entries = entries[:MaxEntries] + } + for _, e := range entries { + en := Entry{Name: e.Name(), Type: typeOf(e.Type())} + if info, err := e.Info(); err == nil { + en.Type = typeOf(info.Mode()) + if st, ok := info.Sys().(*syscall.Stat_t); ok { + en.Owner, en.Group = names.user(int(st.Uid)), names.group(int(st.Gid)) + en.Mode = fmt.Sprintf("%04o", st.Mode&07777) + } + if info.Mode().IsRegular() { + size := info.Size() + en.Size = &size + } + if info.Mode()&fs.ModeSymlink != 0 { + en.Link, _ = os.Readlink(filepath.Join(path, e.Name())) + } + } + for _, f := range []*string{&en.Link} { + if s, what := clean(*f); what != "" { + a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what}) + *f = s + } + } + a.Entries = append(a.Entries, en) + } + return a, nil +} + +// ---- read ---- + +// ReadAnswer is what read answers. +type ReadAnswer struct { + Path string `json:"path"` + Size int64 `json:"size"` // as the file says; 0 for most of /proc and /sys + From int `json:"from"` // the first line answered, counted from 1 + Lines int `json:"lines"` + Text string `json:"text"` + Cut string `json:"cut,omitempty"` + Withheld []int `json:"withheld-lines,omitempty"` + Why string `json:"withheld-because,omitempty"` +} + +// ReadAsk is what a read asks: the lines from From (1 is the first; a negative From counts from the end, as +// tail does), at most Lines of them (0: as many as fit), and at most Bytes bytes. +type ReadAsk struct { + From, Lines, Bytes int +} + +func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) { + if err := r.asItsOwn(); err != nil { + return ReadAnswer{}, err + } + path, err := absolute(raw) + if err != nil { + return ReadAnswer{}, err + } + if err := r.refusedRead(path); err != nil { + return ReadAnswer{}, err + } + bound := ask.Bytes + switch { + case bound <= 0: + bound = DefaultReadSize + case bound > MaxReadSize: + return ReadAnswer{}, fmt.Errorf("%d bytes asked, and one read answers at most %d", bound, MaxReadSize) + } + if ask.Lines < 0 { + return ReadAnswer{}, errors.New("lines is a count: name how many, or none for as many as fit") + } + f, info, err := r.openJudged(path, false, false) + if err != nil { + return ReadAnswer{}, err + } + defer f.Close() + a := ReadAnswer{Path: path, Size: info.Size()} + lines, cut, err := pick(io.LimitReader(f, MaxScan), ask, bound) + if err != nil { + return ReadAnswer{}, fmt.Errorf("%s: %v", path, err) + } + a.Cut = cut + if len(lines) > 0 { + a.From = lines[0].n + } + var text strings.Builder + for _, l := range lines { + s, what := clean(l.text) + if l.block { + s, what = "‹withheld: a key or certificate block›", "a key or certificate block" + } + if what != "" { + a.Withheld = append(a.Withheld, l.n) + a.Why = "a line shaped like a secret is withheld by the content rule (novox/hq ADR 0310 §4)" + } + text.WriteString(s) + if l.newline { + text.WriteByte('\n') + } + } + a.Text, a.Lines = text.String(), len(lines) + return a, nil +} + +type line struct { + n int + text string + newline bool + size int + block bool // inside a BEGIN … END block, whose every line is withheld +} + +// pick reads lines and keeps the ones asked, within bound bytes. A NUL byte is a binary file, refused. +func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) { + br := bufio.NewReaderSize(in, 64<<10) + var kept []line + size, n := 0, 0 + from := ask.From + if from == 0 { + from = 1 + } + var blocks blockState + for { + raw, err := br.ReadString('\n') + if raw == "" && err != nil { + if err == io.EOF { + break + } + return nil, "", err + } + n++ + if strings.IndexByte(raw, 0) >= 0 { + return nil, "", errors.New("it is a binary file, and read answers text") + } + l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw), + block: blocks.in(raw)} + if from > 0 { + if n < from { + continue + } + if ask.Lines > 0 && len(kept) == ask.Lines { + break + } + if size+len(raw) > bound { + return kept, fmt.Sprintf("cut at line %d: the answer holds at most %d bytes", n, bound), nil + } + kept = append(kept, l) + size += len(raw) + } else { + // From the end, as tail does: the last -from lines, kept as they pass. + kept = append(kept, l) + size += len(raw) + for len(kept) > -from || size > bound { + size -= kept[0].size + kept = kept[1:] + } + } + if err == io.EOF { + break + } + } + if from > 0 && n < from && n > 0 { + return nil, "", fmt.Errorf("it has %d lines, and line %d was asked", n, from) + } + if from < 0 && ask.Lines > 0 && len(kept) > ask.Lines { + kept = kept[:ask.Lines] + } + return kept, "", nil +} + +// ---- search ---- + +// Match is one matching line. +type Match struct { + File string `json:"file"` + Line int `json:"line"` + Text string `json:"text"` +} + +// SearchAnswer is what search answers. +type SearchAnswer struct { + Text string `json:"text"` + Path string `json:"path"` + Matches []Match `json:"matches"` + Files int `json:"files-read"` + Bound string `json:"bound-met,omitempty"` + Skipped []string `json:"refused-by-name,omitempty"` + Unreadable int `json:"unreadable,omitempty"` + OtherFS []string `json:"other-file-systems-skipped,omitempty"` + Binary int `json:"binary-skipped,omitempty"` + Withheld []Withheld `json:"withheld,omitempty"` +} + +// SearchAsk is what a search asks. +type SearchAsk struct { + Text, Path string + Depth, Matches int + CaseInsensitive bool +} + +var errBound = errors.New("bound met") + +func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) { + if err := r.asItsOwn(); err != nil { + return SearchAnswer{}, err + } + if ask.Text == "" { + return SearchAnswer{}, errors.New("name the text to find") + } + if strings.ContainsAny(ask.Text, "\n\x00") { + return SearchAnswer{}, errors.New("the text is found within one line: it may not hold a line break") + } + root, err := absolute(ask.Path) + if err != nil { + return SearchAnswer{}, err + } + if err := r.refusedRead(root); err != nil { + return SearchAnswer{}, err + } + depth, matches := ask.Depth, ask.Matches + switch { + case depth == 0: + depth = DefaultDepth + case depth < 0 || depth > MaxDepth: + return SearchAnswer{}, fmt.Errorf("depth %d asked; a search goes at most %d directories deep", depth, MaxDepth) + } + switch { + case matches == 0: + matches = DefaultMatches + case matches < 0 || matches > MaxMatches: + return SearchAnswer{}, fmt.Errorf("%d matches asked; a search answers at most %d", matches, MaxMatches) + } + needle := ask.Text + if ask.CaseInsensitive { + needle = strings.ToLower(needle) + } + a := SearchAnswer{Text: ask.Text, Path: root, Matches: []Match{}} + deadline := r.Now().Add(SearchTime) + rootDev, hasDev := deviceOf(root) + base := strings.Count(root, "/") + if root == "/" { + base = 0 + } + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if r.Now().After(deadline) { + a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime) + return errBound + } + if err != nil { + a.Unreadable++ + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil + } + if p != root { + if why := r.Refused(p); why != "" { + a.Skipped = append(a.Skipped, p) + if d.IsDir() { + return fs.SkipDir + } + return nil + } + } + if d.IsDir() { + if p != root && strings.Count(p, "/")-base > depth { + return fs.SkipDir + } + // The root's file system only, as grep -r with find's -xdev: a hard network mount would hang a + // read past every bound, and /proc and /sys are searched only from within them. + if dev, ok := deviceOf(p); p != root && hasDev && ok && dev != rootDev { + a.OtherFS = append(a.OtherFS, p) + return fs.SkipDir + } + return nil + } + if !d.Type().IsRegular() { + return nil // links are not followed; a device, a fifo or a socket is not text + } + if a.Files >= MaxFiles { + a.Bound = fmt.Sprintf("files: a search reads at most %d files", MaxFiles) + return errBound + } + a.Files++ + return r.searchFile(p, needle, ask.CaseInsensitive, matches, deadline, &a) + }) + if err != nil && !errors.Is(err, errBound) { + return SearchAnswer{}, fmt.Errorf("%s cannot be searched: %s", root, why(err)) + } + return a, nil +} + +func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error { + f, _, err := r.openJudged(p, true, false) + if err != nil { + if errors.Is(err, errRefusedByName) { + a.Skipped = append(a.Skipped, p) + } else { + a.Unreadable++ + } + return nil + } + defer f.Close() + br := bufio.NewReaderSize(io.LimitReader(f, MaxFileScan), 64<<10) + if head, _ := br.Peek(8 << 10); bytes.IndexByte(head, 0) >= 0 { + a.Binary++ + return nil + } + var blocks blockState + n := 0 + for { + raw, err := br.ReadString('\n') + if raw == "" && err != nil { + return nil + } + n++ + if n%1000 == 0 && r.Now().After(deadline) { + a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime) + return errBound + } + block := blocks.in(raw) + hay := raw + if fold { + hay = strings.ToLower(hay) + } + if strings.Contains(hay, needle) { + if len(a.Matches) >= bound { + a.Bound = fmt.Sprintf("matches: %d answered, as asked or the default", bound) + return errBound + } + text := strings.TrimRight(raw, "\r\n") + if r := []rune(text); len(r) > MaxLine { + text = string(r[:MaxLine]) + "…" + } + if s, what := clean(text); what != "" || block { + if block { + s, what = "‹withheld: a key or certificate block›", "a key or certificate block" + } + a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what}) + text = s + } + a.Matches = append(a.Matches, Match{File: p, Line: n, Text: text}) + } + if err != nil { + return nil + } + } +} + +// blockState follows BEGIN … END blocks through a file (the review of #244): a private key's body is base64 a +// line at a time, and one line of it can pass the content rule alone, so every line from a BEGIN through its +// END is withheld — a certificate's too, which costs nothing a read needs. +type blockState struct{ open bool } + +func (b *blockState) in(raw string) bool { + switch { + case strings.Contains(raw, "-----BEGIN "): + b.open = !strings.Contains(raw, "-----END ") + return true + case b.open: + if strings.Contains(raw, "-----END ") { + b.open = false + } + return true + } + return false +} + +// ---- accounts ---- + +// Account is one account as the account file says it, never its password field. +type Account struct { + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Group string `json:"group,omitempty"` + Gecos string `json:"gecos,omitempty"` + Home string `json:"home"` + Shell string `json:"shell"` + Groups []string `json:"groups,omitempty"` +} + +// GroupEntry is one group. +type GroupEntry struct { + Name string `json:"name"` + GID int `json:"gid"` + Members []string `json:"members"` +} + +// AccountAnswer is what account answers. +type AccountAnswer struct { + Accounts []Account `json:"accounts,omitempty"` + Groups []GroupEntry `json:"groups,omitempty"` + Source string `json:"source"` + Cut string `json:"cut,omitempty"` + Withheld []Withheld `json:"withheld,omitempty"` +} + +type accounts struct { + users []Account + groups []GroupEntry +} + +func (a accounts) user(uid int) string { + for _, u := range a.users { + if u.UID == uid { + return u.Name + } + } + return "" +} + +func (a accounts) group(gid int) string { + for _, g := range a.groups { + if g.GID == gid { + return g.Name + } + } + return "" +} + +// names reads the account files; a file it cannot read gives no names, and a component says only numbers. +func (r Reader) names() accounts { + var a accounts + if raw, err := os.ReadFile(r.Passwd); err == nil { + for _, l := range strings.Split(string(raw), "\n") { + f := strings.Split(l, ":") + if len(f) < 7 || strings.HasPrefix(l, "#") { + continue + } + uid, e1 := strconv.Atoi(f[2]) + gid, e2 := strconv.Atoi(f[3]) + if e1 != nil || e2 != nil { + continue + } + a.users = append(a.users, Account{Name: f[0], UID: uid, GID: gid, Gecos: f[4], Home: f[5], Shell: f[6]}) + } + } + if raw, err := os.ReadFile(r.Group); err == nil { + for _, l := range strings.Split(string(raw), "\n") { + f := strings.Split(l, ":") + if len(f) < 4 || strings.HasPrefix(l, "#") { + continue + } + gid, err := strconv.Atoi(f[2]) + if err != nil { + continue + } + members := []string{} + for _, m := range strings.Split(f[3], ",") { + if m = strings.TrimSpace(m); m != "" { + members = append(members, m) + } + } + a.groups = append(a.groups, GroupEntry{Name: f[0], GID: gid, Members: members}) + } + } + return a +} + +func (r Reader) Account(name, group string) (AccountAnswer, error) { + if err := r.asItsOwn(); err != nil { + return AccountAnswer{}, err + } + all := r.names() + if len(all.users) == 0 { + return AccountAnswer{}, fmt.Errorf("%s gave no account: it is unreadable or empty", r.Passwd) + } + out := AccountAnswer{Source: r.Passwd + ", " + r.Group + " (an account kept elsewhere, such as a directory " + + "service or systemd's dynamic accounts, is not here)"} + complete := func(u Account) Account { + u.Group = all.group(u.GID) + for _, g := range all.groups { + if g.GID == u.GID { + u.Groups = append(u.Groups, g.Name) + continue + } + for _, m := range g.Members { + if m == u.Name { + u.Groups = append(u.Groups, g.Name) + } + } + } + return u + } + name, group = strings.TrimSpace(name), strings.TrimSpace(group) + switch { + case name != "": + for _, u := range all.users { + if u.Name == name || strconv.Itoa(u.UID) == name { + out.Accounts = append(out.Accounts, complete(u)) + } + } + if len(out.Accounts) == 0 { + return AccountAnswer{}, fmt.Errorf("no account %q on this node", name) + } + case group != "": + for _, g := range all.groups { + if g.Name == group || strconv.Itoa(g.GID) == group { + // Its members as getent says them, and every account whose own group it is. + for _, u := range all.users { + if u.GID == g.GID && !contains(g.Members, u.Name) { + g.Members = append(g.Members, u.Name) + } + } + out.Groups = append(out.Groups, g) + } + } + if len(out.Groups) == 0 { + return AccountAnswer{}, fmt.Errorf("no group %q on this node", group) + } + default: + for _, u := range all.users { + out.Accounts = append(out.Accounts, complete(u)) + } + out.Groups = all.groups + if len(out.Accounts) > MaxAccounts { + out.Cut = fmt.Sprintf("cut at %d of %d accounts", MaxAccounts, len(out.Accounts)) + out.Accounts = out.Accounts[:MaxAccounts] + } + if len(out.Groups) > MaxAccounts { + out.Cut = strings.TrimPrefix(out.Cut+"; ", "; ") + fmt.Sprintf("cut at %d of %d groups", MaxAccounts, len(out.Groups)) + out.Groups = out.Groups[:MaxAccounts] + } + } + for i := range out.Accounts { + u := &out.Accounts[i] + for _, f := range []*string{&u.Gecos, &u.Home, &u.Shell} { + if s, what := clean(*f); what != "" { + out.Withheld = append(out.Withheld, Withheld{At: "account " + u.Name, Class: what}) + *f = s + } + } + } + sort.SliceStable(out.Accounts, func(i, j int) bool { return out.Accounts[i].UID < out.Accounts[j].UID }) + return out, nil +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// ---- mounts ---- + +// mounts reads the mount points this process sees, by path. /proc/self/mountinfo is readable by every account. +func (r Reader) mounts() map[string]Mount { + out := map[string]Mount{} + raw, err := os.ReadFile(r.MountInfo) + if err != nil { + return out + } + for _, l := range strings.Split(string(raw), "\n") { + f := strings.Fields(l) + dash := -1 + for i, x := range f { + if x == "-" { + dash = i + break + } + } + if len(f) < 5 || dash < 0 || dash+2 >= len(f) { + continue + } + out[unescape(f[4])] = Mount{FS: f[dash+1], Source: unescape(f[dash+2])} + } + return out +} + +// unescape reads mountinfo's octal escapes (\040 for a space). +func unescape(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+3 < len(s) { + if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil { + b.WriteByte(byte(v)) + i += 3 + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} diff --git a/modules/mesh-reads/cmd/mesh-reads/reads_test.go b/modules/mesh-reads/cmd/mesh-reads/reads_test.go new file mode 100644 index 00000000..461892e8 --- /dev/null +++ b/modules/mesh-reads/cmd/mesh-reads/reads_test.go @@ -0,0 +1,562 @@ +package main + +import ( + crand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "testing" + "time" +) + +// A token shaped as a secret, written into files the tests read. +const aToken = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + +func testReader(t *testing.T, places ...string) (Reader, string) { + t.Helper() + dir := t.TempDir() + passwd := filepath.Join(dir, "passwd") + group := filepath.Join(dir, "group") + mountinfo := filepath.Join(dir, "mountinfo") + write(t, passwd, "root:x:0:0::/root:/usr/bin/bash\nops:x:1000:1000:The Operator:/home/ops:/usr/bin/zsh\n"+ + "mesh-reads:x:961:961::/var/lib/mesh-reads:/usr/bin/nologin\n") + write(t, group, "root:x:0:\nwheel:x:998:ops\nops:x:1000:\nmesh-reads:x:961:\ndocker:x:970:ops,builder\n") + data := filepath.Join(dir, "data") + write(t, mountinfo, "22 1 0:21 / / rw,relatime - btrfs /dev/nvme0n1p2 rw\n"+ + "40 22 0:40 / "+strings.ReplaceAll(data, " ", `\040`)+" rw - nfs4 home-server:/tank/data rw\n") + r := Reader{Refuser: Refuser{Places: places}, Passwd: passwd, Group: group, MountInfo: mountinfo, + Euid: func() int { return 961 }, Now: time.Now} + return r, dir +} + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// ADR 0310 §3: run as root, every read is refused, loudly — its bound is an account of its own. +func TestEveryReadIsRefusedAsRoot(t *testing.T) { + r, dir := testReader(t) + r.Euid = func() int { return 0 } + write(t, filepath.Join(dir, "f"), "x\n") + if _, err := r.Read(filepath.Join(dir, "f"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "root") { + t.Errorf("a read as root: %v", err) + } + if _, err := r.Stat([]string{dir}, false); err == nil { + t.Error("a stat as root was answered") + } + if _, err := r.Account("ops", ""); err == nil { + t.Error("an account lookup as root was answered") + } +} + +// ADR 0310 §5: without the controller's list of secret places it does not start: a refusal it promises would be +// silent. +func TestItDoesNotStartWithoutTheNodesSecretPlaces(t *testing.T) { + if _, err := placesFrom(""); err == nil || !strings.Contains(err.Error(), PlacesWord) { + t.Errorf("no places: %v", err) + } + if p, err := placesFrom(`["/var/lib/mesh-vault"]`); err != nil || len(p) != 1 { + t.Errorf("a list: %v %v", p, err) + } + if p, err := placesFrom(`[]`); err != nil || len(p) != 0 { + t.Errorf("an empty list is a node with no secret place: %v %v", p, err) + } +} + +// ADR 0310 §5: a path under the vault's directory, a module's own-secret directory and a known credential file +// are each refused by name before any read — though the account's modes would allow it — and a link to one is +// refused as the place is. +func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) { + r0, dir := testReader(t) + vault := filepath.Join(dir, "var/lib/mesh-vault") + own := filepath.Join(dir, "var/lib/desk-channel") + r, _ := r0, dir + r.Places = []string{vault, own} + for _, p := range []string{ + filepath.Join(vault, "ledger/entries.json"), + filepath.Join(own, "broker"), + filepath.Join(own, "settings.json"), + filepath.Join(dir, "home/ops/.claude/.credentials.json"), + filepath.Join(dir, "home/ops/.ssh/config"), + filepath.Join(dir, "home/ops/.pgpass"), + filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key"), + filepath.Join(dir, "etc/shadow"), + filepath.Join(dir, "srv/tls/server.key"), + } { + write(t, p, "harmless words\n") + if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("%s was read: %v", p, err) + } + } + if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("the vault's directory was listed: %v", err) + } + link := filepath.Join(dir, "tmp/innocent") + write(t, filepath.Join(dir, "tmp/x"), "") + if err := os.Symlink(filepath.Join(own, "broker"), link); err != nil { + t.Fatal(err) + } + if _, err := r.Read(link, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "leads to") { + t.Errorf("a link to a secret place was read: %v", err) + } + // A search under a directory holding a secret place skips it and names it. + write(t, filepath.Join(dir, "home/ops/notes.txt"), "harmless words\n") + a, err := r.Search(SearchAsk{Text: "harmless", Path: filepath.Join(dir, "home"), Depth: 8}) + if err != nil { + t.Fatal(err) + } + if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "notes.txt") || len(a.Skipped) != 3 { + t.Errorf("search: matches %v, skipped %v", a.Matches, a.Skipped) + } + // What a public key or a certificate is, is not refused. + for _, p := range []string{filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key.pub"), filepath.Join(dir, "etc/ssl/certs/ca.pem")} { + write(t, p, "harmless words\n") + if _, err := r.Read(p, ReadAsk{}); err != nil { + t.Errorf("%s was refused: %v", p, err) + } + } +} + +// ADR 0310 §3: what the account's file modes refuse is refused — the first bound, below the names. +func TestAFileTheAccountMayNotReadIsRefusedByItsMode(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("run as root, a file's mode refuses nothing") + } + r, dir := testReader(t) + p := filepath.Join(dir, "operators-only") + write(t, p, "the operator's words\n") + if err := os.Chmod(p, 0o000); err != nil { + t.Fatal(err) + } + if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "may not reach it") { + t.Errorf("a file of mode 0000: %v", err) + } + closed := filepath.Join(dir, "closed") + write(t, filepath.Join(closed, "inside"), "x") + if err := os.Chmod(closed, 0o700&^0o100); err != nil { + t.Fatal(err) + } + defer os.Chmod(closed, 0o755) + a, err := r.Stat([]string{filepath.Join(closed, "inside")}, false) + if err != nil { + t.Fatal(err) + } + if got := a.Paths[0]; !strings.Contains(got.Stopped, "may not reach") { + t.Errorf("a path through a directory it may not search: %+v", got) + } +} + +// ADR 0310 §2 and the bounds: a read over the size bound is cut at a line and says so; more than the most is +// refused; lines and a tail are answered as asked. +func TestAReadIsBounded(t *testing.T) { + r, dir := testReader(t) + p := filepath.Join(dir, "big") + var b strings.Builder + for i := 1; i <= 20000; i++ { + fmt.Fprintf(&b, "line %d of the file\n", i) + } + write(t, p, b.String()) + a, err := r.Read(p, ReadAsk{}) + if err != nil { + t.Fatal(err) + } + if len(a.Text) > DefaultReadSize || !strings.Contains(a.Cut, fmt.Sprint(DefaultReadSize)) || a.From != 1 { + t.Errorf("a default read: %d bytes, cut %q", len(a.Text), a.Cut) + } + if _, err := r.Read(p, ReadAsk{Bytes: MaxReadSize + 1}); err == nil { + t.Error("more than the most a read answers was answered") + } + a, err = r.Read(p, ReadAsk{From: 10, Lines: 2}) + if err != nil || a.Text != "line 10 of the file\nline 11 of the file\n" || a.From != 10 || a.Cut != "" { + t.Errorf("lines 10 and 11: %+v %v", a, err) + } + a, err = r.Read(p, ReadAsk{From: -2}) + if err != nil || a.Text != "line 19999 of the file\nline 20000 of the file\n" || a.From != 19999 { + t.Errorf("the last two lines: %q from %d, %v", a.Text, a.From, err) + } + if _, err := r.Read(p, ReadAsk{From: 30000}); err == nil || !strings.Contains(err.Error(), "20000 lines") { + t.Errorf("a line past the end: %v", err) + } +} + +// A binary file, a directory and a fifo are refused, and the fifo without waiting for a writer. +func TestOnlyATextFileIsRead(t *testing.T) { + r, dir := testReader(t) + bin := filepath.Join(dir, "bin") + write(t, bin, "ELF\x00\x01\x02") + if _, err := r.Read(bin, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "binary") { + t.Errorf("a binary file: %v", err) + } + if _, err := r.Read(dir, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "directory") { + t.Errorf("a directory: %v", err) + } + fifo := filepath.Join(dir, "fifo") + if err := syscall.Mkfifo(fifo, 0o644); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, err := r.Read(fifo, ReadAsk{}); done <- err }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "fifo") { + t.Errorf("a fifo: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("a read of a fifo waits for a writer") + } +} + +// ADR 0310 §4: a line shaped like a secret is withheld, its number said, and the rest is answered. +func TestALineShapedLikeASecretIsWithheld(t *testing.T) { + r, dir := testReader(t) + p := filepath.Join(dir, "app.conf") + write(t, p, "listen = /run/app.sock\ntoken = "+aToken+"\nuser = app\n") + a, err := r.Read(p, ReadAsk{}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(a.Text, aToken) || !strings.Contains(a.Text, "user = app") || + !strings.Contains(a.Text, "listen = /run/app.sock") || fmt.Sprint(a.Withheld) != "[2]" { + t.Errorf("read: %q withheld %v", a.Text, a.Withheld) + } + m, err := r.Search(SearchAsk{Text: "token", Path: dir}) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(m) + if strings.Contains(string(body), aToken) || len(m.Matches) != 1 || len(m.Withheld) != 1 { + t.Errorf("search: %s", body) + } +} + +// The bounds of a search: matches, depth, the most asked, and time — each met is said. +func TestASearchIsBounded(t *testing.T) { + r, dir := testReader(t) + for i := 0; i < 30; i++ { + write(t, filepath.Join(dir, fmt.Sprintf("f%02d", i)), "needle\nneedle\n") + } + a, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: 7}) + if err != nil { + t.Fatal(err) + } + if len(a.Matches) != 7 || !strings.HasPrefix(a.Bound, "matches") { + t.Errorf("seven matches asked: %d, bound %q", len(a.Matches), a.Bound) + } + if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: MaxMatches + 1}); err == nil { + t.Error("more matches than the most were asked and answered") + } + if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Depth: MaxDepth + 1}); err == nil { + t.Error("deeper than the most was asked and answered") + } + deep := t.TempDir() + write(t, filepath.Join(deep, "a/b/c/shallow"), "needle\n") + write(t, filepath.Join(deep, "a/b/c/d/e/deep"), "needle\n") + a, err = r.Search(SearchAsk{Text: "needle", Path: deep, Depth: 3}) + if err != nil { + t.Fatal(err) + } + if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "shallow") { + t.Errorf("three deep: %+v", a.Matches) + } + // Case: as asked. + write(t, filepath.Join(deep, "case"), "NEEDLE\n") + if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case")}); len(a.Matches) != 0 { + t.Error("a match whatever the case, unasked") + } + if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case"), CaseInsensitive: true}); len(a.Matches) != 1 { + t.Error("no match whatever the case, asked") + } + // Time: a clock past the bound stops the walk and says so. + start := time.Now() + calls := 0 + r.Now = func() time.Time { calls++; return start.Add(time.Duration(calls) * SearchTime / 3) } + a, err = r.Search(SearchAsk{Text: "needle", Path: dir}) + if err != nil || !strings.HasPrefix(a.Bound, "time") { + t.Errorf("the time bound: %q %v", a.Bound, err) + } + // A fixed string: a pattern's characters are themselves. + write(t, filepath.Join(deep, "pattern"), "a.c\nabc\n") + if a, _ := r.Search(SearchAsk{Text: "a.c", Path: filepath.Join(deep, "pattern")}); len(a.Matches) != 1 || a.Matches[0].Line != 1 { + t.Errorf("a.c as a fixed string: %+v", a.Matches) + } +} + +// The survey issue 418 was asked for: each component of a path with its owner, group, mode, whether it is a +// link and to what, and whether it is a mount point — the link not followed unless asked. +func TestStatSaysEachComponent(t *testing.T) { + r, dir := testReader(t) + data := filepath.Join(dir, "data") + write(t, filepath.Join(data, "real/file"), "x") + if err := os.Chmod(filepath.Join(data, "real"), os.ModeSetgid|0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(data, "link")); err != nil { + t.Fatal(err) + } + a, err := r.Stat([]string{filepath.Join(data, "link"), filepath.Join(data, "real/file")}, false) + if err != nil { + t.Fatal(err) + } + link := a.Paths[0] + last := link.Components[len(link.Components)-1] + if link.Components[0].Path != "/" || last.Type != "link" || last.Link != "real" || link.LeadsTo != "" { + t.Errorf("the link, not followed: %+v", link) + } + var dataC, realC Component + for _, c := range a.Paths[1].Components { + switch c.Path { + case data: + dataC = c + case filepath.Join(data, "real"): + realC = c + } + } + if dataC.Mount == nil || dataC.Mount.FS != "nfs4" { + t.Errorf("the mount point is not said: %+v", dataC) + } + if realC.Mode != "2750" || realC.Type != "directory" || realC.UID == nil || realC.Perms == "" { + t.Errorf("a component's mode: %+v", realC) + } + if uid := *realC.UID; uid == 1000 && realC.Owner != "ops" { + t.Errorf("owner %d named %q", uid, realC.Owner) + } + a, err = r.Stat([]string{filepath.Join(data, "link")}, true) + if err != nil { + t.Fatal(err) + } + if a.Paths[0].LeadsTo != filepath.Join(data, "real") || a.Paths[0].Leads == nil || a.Paths[0].Leads.Type != "directory" { + t.Errorf("followed: %+v", a.Paths[0]) + } + a, err = r.Stat([]string{filepath.Join(dir, "missing/deeper")}, false) + if err != nil || !strings.Contains(a.Paths[0].Stopped, "does not exist") { + t.Errorf("a missing path: %+v %v", a, err) + } + if _, err := r.Stat([]string{"relative/path"}, false); err == nil { + t.Error("a relative path was answered") + } + many := make([]string, MaxPaths+1) + for i := range many { + many[i] = "/" + } + if _, err := r.Stat(many, false); err == nil { + t.Error("more paths than the most were answered") + } +} + +func TestReadlinkSaysTheTargetAndWhereItLeads(t *testing.T) { + r, dir := testReader(t) + write(t, filepath.Join(dir, "target"), "") + if err := os.Symlink("target", filepath.Join(dir, "l1")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("l1", filepath.Join(dir, "l2")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("nowhere", filepath.Join(dir, "dangling")); err != nil { + t.Fatal(err) + } + a, err := r.Readlink(filepath.Join(dir, "l2")) + if err != nil || !a.IsLink || a.Link != "l1" || a.LeadsTo != filepath.Join(dir, "target") || !a.Exists { + t.Errorf("l2: %+v %v", a, err) + } + a, err = r.Readlink(filepath.Join(dir, "dangling")) + if err != nil || !a.IsLink || a.Exists || !strings.Contains(a.Stopped, "does not exist") { + t.Errorf("dangling: %+v %v", a, err) + } +} + +func TestListSaysEveryEntry(t *testing.T) { + r, dir := testReader(t) + write(t, filepath.Join(dir, "d/a"), "abc") + if err := os.Symlink("a", filepath.Join(dir, "d/b")); err != nil { + t.Fatal(err) + } + a, err := r.List(filepath.Join(dir, "d")) + if err != nil { + t.Fatal(err) + } + if a.Count != 2 || a.Entries[0].Name != "a" || *a.Entries[0].Size != 3 || a.Entries[1].Type != "link" || a.Entries[1].Link != "a" { + t.Errorf("list: %+v", a) + } + for i := 0; i < MaxEntries+5; i++ { + write(t, filepath.Join(dir, "many", fmt.Sprintf("e%05d", i)), "") + } + a, err = r.List(filepath.Join(dir, "many")) + if err != nil || len(a.Entries) != MaxEntries || a.Count != MaxEntries+5 || a.Cut == "" { + t.Errorf("a long directory: %d of %d, cut %q, %v", len(a.Entries), a.Count, a.Cut, err) + } +} + +// getent passwd, getent group and id: an account with every group it is in, a group with its members, never a +// password field. +func TestAccountAndGroupLookup(t *testing.T) { + r, _ := testReader(t) + a, err := r.Account("ops", "") + if err != nil { + t.Fatal(err) + } + u := a.Accounts[0] + sort.Strings(u.Groups) + if u.UID != 1000 || u.Group != "ops" || u.Shell != "/usr/bin/zsh" || fmt.Sprint(u.Groups) != "[docker ops wheel]" { + t.Errorf("ops: %+v", u) + } + if a, err := r.Account("961", ""); err != nil || a.Accounts[0].Name != "mesh-reads" { + t.Errorf("by number: %+v %v", a, err) + } + g, err := r.Account("", "docker") + if err != nil || g.Groups[0].GID != 970 || fmt.Sprint(g.Groups[0].Members) != "[ops builder]" { + t.Errorf("docker: %+v %v", g, err) + } + if _, err := r.Account("nobody-here", ""); err == nil { + t.Error("a missing account was answered") + } + all, err := r.Account("", "") + if err != nil || len(all.Accounts) != 3 || len(all.Groups) != 5 { + t.Errorf("all: %+v %v", all, err) + } + body, _ := json.Marshal(all) + if strings.Contains(string(body), `"x"`) || strings.Contains(string(body), "password") { + t.Errorf("a password field is answered: %s", body) + } +} + +// No answer is larger than the bound, whatever a verb made. +func TestNoAnswerIsLargerThanTheBound(t *testing.T) { + if _, err := bounded(strings.Repeat("x", MaxAnswer+1), nil); err == nil { + t.Error("an answer over the bound was given") + } + if _, err := bounded("small", nil); err != nil { + t.Error(err) + } +} + +// The seat's six verbs, as the controller defines them (mesh-controller internal/catalogue/reads.go). +func TestItServesTheSeatsVerbs(t *testing.T) { + r, _ := testReader(t) + var names []string + for _, tool := range tools(r) { + names = append(names, tool.Name) + } + if fmt.Sprint(names) != "[node-reads.stat node-reads.readlink node-reads.list node-reads.read node-reads.search node-reads.account]" { + t.Errorf("verbs: %v", names) + } +} + +// The review of #244 (required 1): a link flipped between a harmless file and a secret place, while it is read +// and searched, never answers the secret place's content — what was opened is judged, not the name. +func TestALinkFlippedDuringAReadNeverReadsASecretPlace(t *testing.T) { + r, dir := testReader(t) + secret := filepath.Join(dir, "var/lib/mesh-reads") + r.Places = []string{secret} + write(t, filepath.Join(secret, "broker"), "THE-BROKER-CREDENTIAL\n") + write(t, filepath.Join(dir, "plain"), "THE-PLAIN-WORDS\n") + link := filepath.Join(dir, "shared/link") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + targets := []string{filepath.Join(dir, "plain"), filepath.Join(secret, "broker")} + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + tmp := link + ".new" + os.Remove(tmp) + if os.Symlink(targets[i%2], tmp) == nil { + os.Rename(tmp, link) + } + } + }() + leaked := 0 + for i := 0; i < 5000; i++ { + if a, err := r.Read(link, ReadAsk{}); err == nil && strings.Contains(a.Text, "BROKER") { + leaked++ + } + if a, err := r.Search(SearchAsk{Text: "BROKER", Path: filepath.Dir(link)}); err == nil && len(a.Matches) > 0 { + leaked++ + } + } + close(stop) + <-done + if leaked > 0 { + t.Errorf("a secret place was answered %d times through a flipped link", leaked) + } +} + +// The review of #244 (required 2): every line of a private key's block is withheld, in a read and in a search, +// though the file's name is not refused. +func TestEveryLineOfAKeyBlockIsWithheld(t *testing.T) { + r, dir := testReader(t) + key, err := rsa.GenerateKey(crand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + body := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) + p := filepath.Join(dir, "server.pem") + write(t, p, "# the server's key\n"+body+"after = the block\n") + a, err := r.Read(p, ReadAsk{}) + if err != nil { + t.Fatal(err) + } + lines := strings.Count(body, "\n") + for _, l := range strings.Split(strings.TrimSpace(body), "\n")[1:] { + if strings.Contains(a.Text, l) && !strings.HasPrefix(l, "-----END") { + t.Fatalf("a line of the key was answered: %q", l) + } + } + if len(a.Withheld) != lines || !strings.Contains(a.Text, "after = the block") || !strings.Contains(a.Text, "the server's key") { + t.Errorf("withheld %d of the block's %d lines; text:\n%s", len(a.Withheld), lines, a.Text) + } + // A search for a character every base64 line holds answers none of the key. + m, err := r.Search(SearchAsk{Text: "A", Path: p, Matches: MaxMatches}) + if err != nil { + t.Fatal(err) + } + for _, x := range m.Matches { + if !strings.HasPrefix(x.Text, "‹withheld") { + t.Errorf("a search answered a line of the key: %q", x.Text) + } + } +} + +// The review of #244 (required 3 and should): histories, browser profiles, a process's private entries and the +// cluster credentials are refused by name. +func TestHistoriesProfilesAndProcessesAreRefusedByName(t *testing.T) { + var r Refuser + for _, p := range []string{"/home/ops/.zsh_history", "/home/ops/.bash_history", "/root/.psql_history", + "/home/ops/.viminfo", "/home/ops/.mozilla/firefox/x.default/logins.json", + "/home/ops/.config/google-chrome/Default/Cookies", "/home/ops/.config/chromium/Default/Login Data", + "/proc/self/environ", "/proc/1234/mem", "/proc/self/fd/3", "/proc/thread-self/root/etc/shadow", + "/etc/rancher/k3s/k3s.yaml", "/etc/kubernetes/admin.conf"} { + if r.Refused(p) == "" { + t.Errorf("%s is not refused", p) + } + } + for _, p := range []string{"/proc/mdstat", "/proc/self/status", "/home/ops/.config/i3/config", "/etc/hosts"} { + if why := r.Refused(p); why != "" { + t.Errorf("%s is refused: %s", p, why) + } + } +} diff --git a/modules/mesh-reads/cmd/mesh-reads/refuse.go b/modules/mesh-reads/cmd/mesh-reads/refuse.go new file mode 100644 index 00000000..9a132046 --- /dev/null +++ b/modules/mesh-reads/cmd/mesh-reads/refuse.go @@ -0,0 +1,129 @@ +package main + +import ( + "path/filepath" + "strings" + + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// What is refused by name, before any read (novox/hq ADR 0310 §5), and what an answer withholds (§4). +// +// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret +// directory, every credential file and grant directory, the vault's directories — come from the controller, +// which composes them from the manifests (MESH_SECRET_PLACES). The known credential files of the programs +// on a machine are this module's list, below: the mesh does not write them, so nothing else names them. Both +// hold whatever the account's file modes would allow: the file modes are the first bound, the names the second. + +// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys. +var knownDirs = map[string]bool{ + ".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true, + ".mozilla": true, ".thunderbird": true, // browser and mail profiles: saved logins and cookies +} + +// knownNames are file names that are a credential whatever directory they are in. +var knownNames = map[string]bool{ + ".credentials.json": true, // the agent's credentials (Claude Code) + ".netrc": true, ".pgpass": true, ".git-credentials": true, ".npmrc": true, ".pypirc": true, + ".my.cnf": true, ".vault-token": true, "credentials": true, "credentials.json": true, + "id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true, + "shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true, + "broker": true, // the mesh's name for a module's bus credential + // Shell and program histories: where a token is typed. + ".bash_history": true, ".zsh_history": true, ".histfile": true, ".python_history": true, ".psql_history": true, + ".mysql_history": true, ".lesshst": true, ".viminfo": true, ".node_repl_history": true, ".sqlite_history": true, + ".rediscli_history": true, +} + +// knownSuffixes are file name endings a credential has. +var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".creds", ".nk", ".kdbx", "_key"} + +// knownPaths are the places on a machine that hold credentials and are not a module's. +var knownPaths = []string{ + "/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private", + "/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets", + "/etc/rancher", "/etc/kubernetes/admin.conf", +} + +// knownProfiles are directories under an account's .config that hold a browser's saved logins and cookies. +var knownProfiles = map[string]bool{"google-chrome": true, "chromium": true, "BraveSoftware": true, "vivaldi": true, + "microsoft-edge": true} + +// procPrivate are the entries of a process under /proc that reach its memory, its environment or its files. +var procPrivate = map[string]bool{"environ": true, "mem": true, "fd": true, "map_files": true, "root": true, + "cwd": true} + +// Refuser says whether a path is a place a secret is kept. +type Refuser struct { + // Places are the mesh's, from the controller: refused with everything beneath them. + Places []string +} + +// Refused says why a path may not be read, or "" when it may. The path is clean and absolute. +func (r Refuser) Refused(path string) string { + path = filepath.Clean(path) + for _, p := range r.Places { + if within(path, p) { + return "it is a place the mesh keeps a secret (" + p + ")" + } + } + for _, p := range knownPaths { + if within(path, p) { + return "it is a place a machine keeps credentials (" + p + ")" + } + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] { + return "it reaches a process's " + parts[2] + } + for i, part := range parts { + if i > 0 && parts[i-1] == ".config" && knownProfiles[part] { + return "it is " + part + "'s profile, which keeps saved logins" + } + if knownDirs[part] { + return "it is in " + part + ", which holds an account's credentials" + } + if i == len(parts)-1 { + if knownNames[part] { + return part + " is a known credential file" + } + for _, s := range knownSuffixes { + if strings.HasSuffix(part, s) && len(part) > len(s) { + return part + " is named as a key or a credential (" + s + ")" + } + } + if strings.HasSuffix(part, ".pem") && (strings.Contains(part, "key") || strings.Contains(part, "private")) { + return part + " is named as a private key" + } + if part == "config.json" && i > 0 && parts[i-1] == ".docker" { + return "a container registry login is kept there" + } + } + } + return "" +} + +func within(path, place string) bool { + return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/") +} + +// Withheld is one piece of an answer the content rule kept back: where it was, and the class it carried. +type Withheld struct { + At string `json:"at"` + Class string `json:"why"` +} + +// clean is the content rule's secret class (mesh-sdk outward, novox/hq ADR 0310 §4) over one piece of an +// answer: the piece, or a mark in its place when it is shaped like a secret. +// +// **What a file holds, not what the file system names.** It judges a file's lines, a matching line, a link's +// target, a mount's source and an account's words. A path and an entry's name are not judged: they are what +// the caller named or the file system calls a thing, and the rule's random-run test takes a temporary +// directory's or a container layer's name for a token, which would withhold the very survey asked for. A +// place where a secret sits by name is refused before any read instead (Refuser). +func clean(s string) (string, string) { + if refusal, ok := outward.CheckSecret(s); !ok { + return "‹withheld: " + refusal.What + "›", refusal.What + } + return s, "" +} diff --git a/modules/mesh-reads/go.mod b/modules/mesh-reads/go.mod new file mode 100644 index 00000000..25037a27 --- /dev/null +++ b/modules/mesh-reads/go.mod @@ -0,0 +1,5 @@ +module mesh-reads + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 diff --git a/modules/mesh-reads/go.sum b/modules/mesh-reads/go.sum new file mode 100644 index 00000000..4367b997 --- /dev/null +++ b/modules/mesh-reads/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 h1:1E5iapAO1o4vkwV/8CUBQvNfL3OjXRtiQGBaKHXJVW8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-reads/module.json b/modules/mesh-reads/module.json new file mode 100644 index 00000000..4a664485 --- /dev/null +++ b/modules/mesh-reads/module.json @@ -0,0 +1,54 @@ +{ + "module": "mesh-reads", + "version": "1", + "runs-as": "mesh-reads", + "claims": [ + { + "name": "node-reads", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account" + ] + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-reads", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-reads", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-reads" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-reads", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-reads", + "binary": "mesh-reads", + "loads": [ + "mesh-reads" + ] + } + ] + } +}