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) || m.RunsAs != "" { return nil } 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)} } // 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. Sorted, each once, and a // place under another dropped, so the list says each refusal once. func (r Resolution) secretPlaces() []string { seen := map[string]bool{} add := func(path string) { path = strings.TrimSpace(path) if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") { return } path = filepath.Clean(path) if path == "/" { return } seen[path] = true } for _, m := range r.Modules { for _, own := range m.OwnSecrets { add(filepath.Dir(own.Path)) } for _, to := range m.SecretRequirements() { for _, f := range m.SecretFiles(to) { add(f.Path) } } for _, where := range m.Grants { add(where) } add(m.Keeps) if providesSecret(m) { for _, res := range m.Resources { if fmt.Sprint(res["type"]) == "directory" { path, _ := res["path"].(string) add(path) } } } } 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 } 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) (map[string]string, error) { if !m.ClaimsSeat(ReadsSeat) { return words, nil } body, err := json.Marshal(r.secretPlaces()) 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 }