package catalogue import ( "encoding/json" "fmt" "path/filepath" "sort" "strings" ) // A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8). // // **A seat of its own on every node.** 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 are the reads ADR 0268 §5 has narrow tools // take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every // node needs them, the control-node included, and another module could offer them instead of the tool // runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name. // // **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259 // §8), so the node's file modes bound what it sees — never the operator's account, which sees the // operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept, // which the controller names for it (secretPlaces), and every answer passes the content rule's secret class // (mesh-sdk's outward, ADR 0310 §4). // ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310). const ReadsSeat = "node-reads" // ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is // kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5). const ReadsSecretPlaces = "MESH_SECRET_PLACES" // readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the // build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in // every answer it cuts. func readsVerbs() []Verb { return []Verb{ {Name: "stat", Description: "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 holder's own account, which " + "holds no secret: a component it may not reach says so. At most 200 paths in one call.", Input: schema(map[string]string{ "paths": "one absolute path, or several separated by line breaks", "follow": "\"true\": also say where the path leads once every link is followed", }, []string{"paths"}, "follow"), Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}}, {Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " + "link is followed, and whether that exists.", Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}), Replaces: []string{"readlink", "readlink -f", "realpath"}}, {Name: "list", Description: "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.", Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}), Replaces: []string{"ls", "ls -la"}}, {Name: "read", Description: "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 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 the holder's account may.", Input: schema(map[string]string{ "path": "an absolute path to a file", "from": "the first line to answer, counted from 1 (optional)", "lines": "how many lines to answer (optional)", "bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", }, []string{"path"}), Replaces: []string{"cat", "head", "tail", "less"}}, {Name: "search", Description: "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 and 4 MiB per file; 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.", Input: schema(map[string]string{ "text": "the fixed string to find", "path": "an absolute path to a file or a directory", "depth": "how many directories deep, up to 8 (optional)", "matches": "the most matches to answer, up to 500 (optional)", "case": "\"false\": match whatever the case (optional)", }, []string{"text", "path"}, "case"), Replaces: []string{"grep", "grep -r", "grep -F"}}, {Name: "account", Description: "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.", Input: schema(map[string]string{ "name": "an account's name or number (optional)", "group": "a group's name or number (optional)", }, nil), Replaces: []string{"getent passwd", "getent group", "id", "groups"}}, } } // ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an // account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would // read as the operator's account, which sees the operator's keys and the agent's credentials. func ReadsHolderProblems(m Manifest) []string { if !m.ClaimsSeat(ReadsSeat) { return nil } if m.RunsAs == "" { return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ "bounded by that account's file modes, and the machine's runtime runs as the operator's account "+ "(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)} } // In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes // are what bound its reads. for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs { continue } if groups, said := r["groups"]; said { if list, ok := groups.([]any); !ok || len(list) > 0 { return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+ "reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)} } } } return nil } // secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310 // §5): the directory of every module's own secret, every credential file a module is given and every grant // directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed // secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it, // a directory declared without a path included. Sorted, each once, and a place under another dropped, so the // list says each refusal once. // // **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the // module named: a list that silently shrank would be a refusal the decision promises and nothing does. func (r Resolution) secretPlaces(with Rendering) ([]string, error) { seen := map[string]bool{} var problems []string add := func(module, what, path string) { path = strings.TrimSpace(path) if path == "" { return } if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") { problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node)) return } path = filepath.Clean(path) if path == "/" { problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what)) return } seen[path] = true } for _, m := range r.Modules { for name, own := range m.OwnSecrets { add(m.Module, "own secret "+name, own.Path) if strings.HasPrefix(own.Path, "/") { add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path)) } } for _, to := range m.SecretRequirements() { for _, f := range m.SecretFiles(to) { add(m.Module, "credential file for "+to, f.Path) } } for to, where := range m.Grants { add(m.Module, "grant directory for "+to, where) } add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps) if providesSecret(m) { dirs := dirsFor(m, with) for _, id := range sortedKeys(dirs) { add(m.Module, "directory "+id, dirs[id]) } } } if len(problems) > 0 { sort.Strings(problems) return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+ "one (novox/hq ADR 0310 §5): %s", ReadsSeat, r.Node, strings.Join(problems, "; ")) } all := make([]string, 0, len(seen)) for p := range seen { all = append(all, p) } sort.Strings(all) var out []string for _, p := range all { if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") { continue } out = append(out, p) } return out, nil } func providesSecret(m Manifest) bool { for _, p := range m.Provides { if p.Name == "secret" { return true } } return false } // readsWords is what the holder of node-reads is given beside its own words: the node's secret places. func (r Resolution) readsWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) { if !m.ClaimsSeat(ReadsSeat) { return words, nil } places, err := r.secretPlaces(with) if err != nil { return nil, err } body, err := json.Marshal(places) if err != nil { return nil, err } if words == nil { words = map[string]string{} } if _, has := words[ReadsSecretPlaces]; has { return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)", m.Module, ReadsSecretPlaces) } words[ReadsSecretPlaces] = string(body) return words, nil }