diff --git a/modules/mesh-files/README.md b/modules/mesh-files/README.md index b2e492154..812a4b183 100644 --- a/modules/mesh-files/README.md +++ b/modules/mesh-files/README.md @@ -58,7 +58,7 @@ an override the override and the owned paths it went through. The caller is the ## What it refuses - A place a secret is kept (`MESH_SECRET_PLACES`, and the known credential files in `refuse.go`) is refused by - name for `read`, `list` and `search`. Copying or moving such a place is allowed: nothing is printed. + name for `read` and `search`; `list` answers its entries, never content, marked `secret-place`. Copying or moving such a place is allowed: nothing is printed. - A copy or move to or from `:/path` is refused: copying between nodes comes in step 2 (issue 903). - A write without a reason; a path that is not absolute; a mode that is not a mode. diff --git a/modules/mesh-files/cmd/mesh-files/files_test.go b/modules/mesh-files/cmd/mesh-files/files_test.go index d21eee723..73e6b9068 100644 --- a/modules/mesh-files/cmd/mesh-files/files_test.go +++ b/modules/mesh-files/cmd/mesh-files/files_test.go @@ -24,6 +24,7 @@ type fakeRoot struct { calls []string cat map[string]string out map[string]string // the answer to a whole call, "bin arg arg" + fail map[string]error // the error of a whole call err error } @@ -31,6 +32,9 @@ func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, bin+" "+strings.Join(args, " ")) + if e, ok := r.fail[bin+" "+strings.Join(args, " ")]; ok { + return nil, e + } if o, ok := r.out[bin+" "+strings.Join(args, " ")]; ok { return []byte(o), nil } @@ -699,11 +703,20 @@ func TestTheReadsFallBackToRoot(t *testing.T) { if g.root.called("/usr/bin/cat -- " + locked + "/.ssh/id_rsa") { t.Error("root read a credential file") } - // a secret place is refused by name, and root is never asked - g.root.calls = nil - if _, err := g.List(g.path("home/.ssh")); err == nil { - t.Error("a secret place was listed") + // a secret place is listed, as root lists it, its entries marked and never their content (ADR 0394 §7); a path + // that is not there under a directory the account may not enter is said missing (design 39 parts §15) + secretDir := locked + "/.ssh" + g.root.out["/usr/bin/find -H "+secretDir+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] = + "f\t1000\t1000\t600\t9\t\tid_rsa\x00" + if ls, err := g.List(secretDir); err != nil || ls.Secret == "" || len(ls.Entries) != 1 || ls.Entries[0].Name != "id_rsa" { + t.Errorf("a secret place was not listed through root: %+v %v", ls, err) } + g.root.fail = map[string]error{"/usr/bin/stat -c " + statFormat + " -- " + in + "/gone": errors.New( + "sudo -n /usr/bin/stat: stat: cannot statx '" + in + "/gone': No such file or directory")} + if st, err := g.Stat([]string{in + "/gone"}, false); err != nil || !strings.Contains(st.Paths[0].Stopped, "does not exist") { + t.Errorf("a missing path under a closed directory: %+v %v", st, err) + } + g.root.calls = nil if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 { t.Errorf("a credential file was read: %v %v", err, g.root.calls) } diff --git a/modules/mesh-files/cmd/mesh-files/main.go b/modules/mesh-files/cmd/mesh-files/main.go index c6b7af439..68c6eb232 100644 --- a/modules/mesh-files/cmd/mesh-files/main.go +++ b/modules/mesh-files/cmd/mesh-files/main.go @@ -171,7 +171,9 @@ func object(props map[string]any, required ...string) map[string]any { // The descriptions of the reads are the controller's (internal/catalogue/files.go): the reads' own, with // where they are read as restated for the operator's account. -const asAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " + +const readsAs = "as the node's operator account, through sudo -n where its rights do not reach" + +const asAccount = readsAs + "; a place a " + "secret is kept, or a known credential file, is refused by name" func boolArg(a map[string]any, k string) bool { return arg(a, k) == "true" } @@ -266,7 +268,7 @@ func tools(f *Files) []stdio.Tool { func readTools(r Reader) []stdio.Tool { ts := rawReadTools(r) for i := range ts { - if !strings.Contains(ts[i].Description, asAccount) { + if !strings.Contains(ts[i].Description, readsAs) { ts[i].Description += " Read " + asAccount + "." } } @@ -286,8 +288,9 @@ func rawReadTools(r Reader) []stdio.Tool { 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.", + "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept, at any depth "+ + "beneath it, is listed too and marked secret-place: its entries, never their content; what reaches a "+ + "process's memory or files (/proc//environ, mem, fd, map_files, root, cwd) is refused by name. Read "+readsAs+".", 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 "+ diff --git a/modules/mesh-files/cmd/mesh-files/reads.go b/modules/mesh-files/cmd/mesh-files/reads.go index db438d4db..fc9c67f3e 100644 --- a/modules/mesh-files/cmd/mesh-files/reads.go +++ b/modules/mesh-files/cmd/mesh-files/reads.go @@ -100,6 +100,12 @@ var errRefusedByName = errors.New("refused by name") // (/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) { + return r.openJudgedIn(path, nofollow, dir, false) +} + +// openJudgedIn is openJudged where a list may open a place a secret is kept (listing it reads no content; +// novox/hq ADR 0394 §7): what reaches a process's memory or files stays refused. +func (r Reader) openJudgedIn(path string, nofollow, dir, listing bool) (*os.File, fs.FileInfo, error) { stat := os.Stat if nofollow { stat = os.Lstat @@ -136,9 +142,9 @@ func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileIn 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 0394 §5)", path) } - if why := r.Refused(real); why != "" { + if why := r.Refused(real); why != "" && !(listing && !reachesProcess(real)) { f.Close() - return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §5): %w", path, real, why, errRefusedByName) + return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §7): %w", path, real, why, errRefusedByName) } return f, info, nil } @@ -308,6 +314,10 @@ var rootTypes = map[string]string{"regular file": "file", "regular empty file": func (r Reader) componentAsRoot(p string, names accounts, mounts map[string]Mount) (Component, bool) { out, err := r.Root.Run("/usr/bin/stat", "-c", statFormat, "--", p) f := strings.Split(strings.TrimSpace(string(out)), "|") + if err != nil && strings.Contains(err.Error(), "No such file or directory") { + // root reached the place and it is not there: said as missing, not as refused by the file modes. + return Component{Path: p, Unreachd: why(fs.ErrNotExist)}, true + } if err != nil || len(f) != 6 { return Component{}, false } @@ -478,6 +488,9 @@ type ListAnswer struct { Count int `json:"count"` Cut string `json:"cut,omitempty"` Withheld []Withheld `json:"withheld,omitempty"` + // Secret says why a read under the place is refused, when the directory is a place a secret is kept: its + // entries are answered, never their content (novox/hq ADR 0394 §7). + Secret string `json:"secret-place,omitempty"` } // refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a @@ -494,17 +507,45 @@ func (r Reader) refusedRead(path string) error { return nil } +// listedPlace judges a directory asked to be listed, and where it leads: a place a secret is kept is listed +// (its entries, never content) and said so; what reaches a process's memory or files is refused by name +// (novox/hq ADR 0394 §7). +func (r Reader) listedPlace(path string) (string, error) { + var secret string + judge := func(p, how string) error { + why := r.Refused(p) + switch { + case why == "": + case reachesProcess(p): + return fmt.Errorf("%s%s is refused by name: %s (novox/hq ADR 0394 §7)", path, how, why) + case secret == "": + secret = why + } + return nil + } + if err := judge(path, ""); err != nil { + return "", err + } + if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path { + if err := judge(leads, " leads to "+leads+", which"); err != nil { + return "", err + } + } + return secret, nil +} + func (r Reader) List(raw string) (ListAnswer, error) { path, err := absolute(raw) if err != nil { return ListAnswer{}, err } - if err := r.refusedRead(path); err != nil { + secret, err := r.listedPlace(path) + if err != nil { return ListAnswer{}, err } names := r.names() var all []Entry - dir, _, err := r.openJudged(path, false, true) + dir, _, err := r.openJudgedIn(path, false, true, true) switch { case err != nil && r.Root != nil && errors.Is(err, fs.ErrPermission): if all, err = r.listAsRoot(path, names); err != nil { @@ -540,15 +581,15 @@ func (r Reader) List(raw string) (ListAnswer, error) { } all = append(all, en) } - return r.finishList(path, all, len(entries)), nil + return r.finishList(path, all, len(entries), secret), nil } - return r.finishList(path, all, len(all)), nil + return r.finishList(path, all, len(all), secret), nil } // finishList bounds a list and holds each link's target to the content rule. total is how many entries the // directory holds. -func (r Reader) finishList(path string, all []Entry, total int) ListAnswer { - a := ListAnswer{Path: path, Count: total, Entries: []Entry{}} +func (r Reader) finishList(path string, all []Entry, total int, secret string) ListAnswer { + a := ListAnswer{Path: path, Count: total, Entries: []Entry{}, Secret: secret} if total > MaxEntries { a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, total) } diff --git a/modules/mesh-files/cmd/mesh-files/reads_test.go b/modules/mesh-files/cmd/mesh-files/reads_test.go index 162e5f720..bb94ef3d4 100644 --- a/modules/mesh-files/cmd/mesh-files/reads_test.go +++ b/modules/mesh-files/cmd/mesh-files/reads_test.go @@ -85,8 +85,33 @@ func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) { 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) + // a list of a place a secret is kept answers its entries, never content, marked (ADR 0394 §7) + if a, err := r.List(vault); err != nil || a.Secret == "" || a.Count != 1 || a.Entries[0].Name != "ledger" { + t.Errorf("the vault's directory was not listed as a secret place: %+v %v", a, err) + } + if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || a.Count != 1 { + t.Errorf("beneath the vault's directory: %+v %v", a, err) + } + if a, err := r.List(filepath.Join(dir, "home/ops")); err != nil || a.Secret != "" { + t.Errorf("a plain directory is marked: %+v %v", a, err) + } + // a link in a secret place: the entry is answered, its target held to the content rule (ADR 0394 §8) + if err := os.Symlink(aToken, filepath.Join(vault, "ledger/tokenlink")); err != nil { + t.Fatal(err) + } + if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || len(a.Withheld) != 1 || + a.Withheld[0].At != "a link's target" || strings.Contains(a.Entries[0].Link+a.Entries[1].Link, aToken) { + t.Errorf("a secret-shaped target in a secret place: %+v %v", a, err) + } + // the served text of list says it lists a secret place, and no longer that it refuses one + for _, tl := range readTools(r) { + if strings.HasSuffix(tl.Name, ".list") && (strings.Contains(tl.Description, "known credential file, is refused") || + !strings.HasSuffix(tl.Description, "where its rights do not reach.")) { + t.Errorf("the list text: %s", tl.Description) + } + } + if _, err := r.List("/proc/self/fd"); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("a process's open files were listed: %v", err) } link := filepath.Join(dir, "tmp/innocent") write(t, filepath.Join(dir, "tmp/x"), "") diff --git a/modules/mesh-files/cmd/mesh-files/refuse.go b/modules/mesh-files/cmd/mesh-files/refuse.go index df2d55865..afee44679 100644 --- a/modules/mesh-files/cmd/mesh-files/refuse.go +++ b/modules/mesh-files/cmd/mesh-files/refuse.go @@ -7,7 +7,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/outward" ) -// What is refused by name, before any read (novox/hq ADR 0394 §5), and what an answer withholds (§4). +// What is refused by name, before any read (novox/hq ADR 0394 §7), 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, @@ -103,6 +103,12 @@ func (r Refuser) Refused(path string) string { return "" } +// reachesProcess says whether a path is an entry of a process that reaches its memory, environment or files. +func reachesProcess(path string) bool { + parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/") + return len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] +} + func within(path, place string) bool { return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/") }