package catalogue import ( "fmt" "regexp" "sort" "strings" ) // A credential and a configuration file meeting. // // **The gap this closes.** A granted credential arrives as a file whose entire content is the // password. That is what a program reading a password file wants — and most programs do not read // one. They read `KEY=value`, or a JSON document with the password at some path inside it, or a // YAML file in a home directory. Before this, a module in that position could be handed the bare // value or nothing, and both are useless. // // The mesh cannot compose the document, because it discarded the value (novox/hq ADR 0024). So // the module supplies the document with a hole in it, the mesh delivers the value sealed beside // it, and the host — the only thing that ever sees both — puts one into the other on the machine. // // The host has always been able to do this. Nothing filled the values in, so the hole could be // written and never closed, and the host refused the file. That refusal was correct and the // feature was unreachable. // placeholder is what a module's file content says where a sealed value belongs: ${secret:name}. // // The same expression the host matches, written out again rather than shared: they are separate // repositories and this is a wire format, like the shape of the declaration itself. // // **Nothing here can check that they agree, and the claim that something did was wrong.** A unit // test in this repository can only assert what this repository already believes. What proves it is // the lab, where a real host receives a real declaration and the file arrives filled — and where // the two expressions disagreeing shows up as a placeholder written through to a machine. var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) // secretsUsed are the names a file's content asks for, in the order they first appear. func secretsUsed(content string) []string { var used []string seen := map[string]bool{} for _, m := range placeholder.FindAllStringSubmatch(content, -1) { if !seen[m[1]] { seen[m[1]] = true used = append(used, m[1]) } } return used } // sealedFor is every credential a module may name from inside one of its own files. // // **Exactly what it already declared, and nothing else.** A module reaches its own secrets and the // credentials it was granted for what it requires — both written down in its own manifest. It // cannot name another module's, which is not an oversight: two modules on one machine are as // separate as two on different machines, and letting one read the other's credential by guessing a // name would end that, to save writing a file. func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, error) { sealed := map[string]string{} for name := range m.OwnSecrets { if value := with.Needed[m.Module][name]; value != "" { sealed[name] = value } } for _, to := range sortedKeys(m.Secrets) { if _, taken := sealed[to]; taken { // A module whose own secret and whose requirement share a name. Refused rather than // settled by precedence: whichever won, the manifest would read as though the other // had, and the file would hold the credential for the wrong thing while every check // passed. return nil, fmt.Errorf( "%s has a secret of its own called %q and also requires %q, so a file saying "+ "${secret:%s} could mean either — rename one of them", m.Module, to, to, to) } for i := range needs { // `For == m.Module`, not name alone: on a node with two modules requiring the same // provision, both appear in `needs`, and matching by name would fill ${secret:X} with // whichever came last — the other module's credential (novox/hq 04-ISSUES/022). The // `secrets:`-map path already guards this way; the ${secret:…} placeholder path did not. if needs[i].Name == to && needs[i].For == m.Module && needs[i].Sealed != "" { sealed[to] = needs[i].Sealed } } } return sealed, nil } // SecretsInEnvironment is the key a container carries to say, out loud, that a secret reaches // its process through the environment — and why. // // **A secret reaches a process as a file** (novox/hq ADR 0086). The mesh seals a value to the // machine and the host writes it at 0600; a container that then reads it from an env-file hands // it to the runtime, which prints it in `docker inspect` and keeps it in the process's /proc entry // for anything on the machine that can talk to the runtime (novox/hq 04-ISSUES/041). Some software // reads its configuration from the environment and nothing else, and for that this key exists: a // reason, on the container, so a reader can tell from the manifest which secrets are exposed that // way and which are not. Without it, a container whose env-file carries a secret is refused. // // Catalogue-level: the host never sees this key. const SecretsInEnvironment = "secrets-in-environment" // refuseSecretsInEnvironment is the check. `secretFiles` is every file of this module whose // content names a secret. func refuseSecretsInEnvironment(resource map[string]any, secretFiles map[string]bool, module string) error { if fmt.Sprint(resource["type"]) != "container" { return nil } name := fmt.Sprint(resource["name"]) if env, ok := resource["env"].(map[string]any); ok { for key, value := range env { if strings.Contains(fmt.Sprint(value), "${secret:") { return fmt.Errorf( "%s's container %s puts ${secret:…} in its env (%s). A secret is never filled into an "+ "environment variable: put it in a file the module declares and mount that, or name the "+ "file in env-file and say %q why", module, name, key, SecretsInEnvironment) } } } reason, _ := resource[SecretsInEnvironment].(string) if strings.TrimSpace(reason) != "" { return nil } if files, ok := resource["env-file"].([]any); ok { for _, f := range files { if secretFiles[fmt.Sprint(f)] { return fmt.Errorf( "%s's container %s reads %s as an env-file, and that file carries a secret, so the secret "+ "reaches the process environment — readable in `docker inspect` and /proc (novox/hq "+ "04-ISSUES/041). Mount the secret's file and point the program at it, or, if the program "+ "reads only its environment, say so on the container: %q: \"\"", module, name, f, SecretsInEnvironment) } } } return nil } // secretFilesOf is the paths of a module's file resources whose content names a secret. func secretFilesOf(resources []map[string]any) map[string]bool { out := map[string]bool{} for _, r := range resources { if fmt.Sprint(r["type"]) != "file" { continue } if content, ok := r["content"].(string); ok && len(secretsUsed(content)) > 0 { out[fmt.Sprint(r["path"])] = true } } return out } // intoFile gives a file the sealed values its content asks for. // // A name the module never declared is refused here rather than on the machine. The host would // refuse it too — but it would do so having already been handed a declaration, which reads as the // mesh sending something broken, and the name it could not find is a manifest's typo. func intoFile(resource map[string]any, sealed map[string]string, module string) error { if fmt.Sprint(resource["type"]) != "file" { return nil } content, ok := resource["content"].(string) if !ok { return nil } used := secretsUsed(content) if len(used) == 0 { return nil } into := map[string]any{} for _, name := range used { value := sealed[name] if value == "" { return fmt.Errorf( "%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+ "name and requires nothing called that either. A file may name %s", module, name, module, namesOr(sealed)) } into[name] = value } resource["secrets"] = into return nil } // namesOr says what a module could have written, because the answer to "that name is wrong" is // almost always one of two or three right ones. func namesOr(sealed map[string]string) string { if len(sealed) == 0 { return "nothing — it has no secrets of its own and requires nothing that grants one" } var names []string for name := range sealed { names = append(names, fmt.Sprintf("%q", name)) } sort.Strings(names) return join(names) } func join(names []string) string { switch len(names) { case 1: return names[0] case 2: return names[0] + " or " + names[1] } out := "" for i, n := range names[:len(names)-1] { if i > 0 { out += ", " } out += n } return out + " or " + names[len(names)-1] }