package resolution import ( "fmt" "regexp" "sort" "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // 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 mcat.Manifest, needs []mcat.Needed, with mcat.Rendering) (map[string]string, error) { sealed := map[string]string{} for name := range m.OwnSecrets.Plain() { if value := with.Needed[m.Module][name]; value != "" { sealed[name] = value } else if with.Foreseen[m.Module][name] { // A stand-in where its own file has one (the Foreseeing and Planning modes, issue 774): a file naming it // composes as its own file does, never refused for a value no such composition reads. sealed[name] = mcat.ForeseenSealed } } for _, to := range m.SecretRequirements() { for _, file := range m.SecretFiles(to) { key := mcat.SecretLocal(to, file.Local) if _, taken := sealed[key]; 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, key, key, key) } 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). And // the local name, where the module keeps several (ADR 0094). if needs[i].Name == to && needs[i].For == m.Module && needs[i].Local == file.Local && needs[i].Sealed != "" { sealed[key] = needs[i].Sealed } } } } return sealed, nil } // 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, mcat.SecretsInEnvironment) } } } reason, _ := resource[mcat.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, mcat.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. // // **An own secret issued outside the mesh that nobody gave is filled with nothing** (novox/hq issue 401): the mesh // never makes one (ADR 0228), and a file is where the module reads it, so the place says empty — the module sees it // was not given, where a value the mesh made looked given and wrong. notGiven names those secrets. func intoFile(resource map[string]any, sealed map[string]string, notGiven map[string]bool, 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 == "" && notGiven[name] { content = strings.ReplaceAll(content, "${secret:"+name+"}", "") continue } 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["content"] = content if len(into) > 0 { resource["secrets"] = into } return nil } // outsideNotGiven is each own secret of a module issued outside the mesh that this composition holds no value for // (novox/hq issue 401): what intoFile fills with nothing, and what nothing restarts on. func outsideNotGiven(m mcat.Manifest, given map[string]string) map[string]bool { out := map[string]bool{} for name, own := range m.OwnSecrets.Plain() { if own.IssuedBy == mcat.IssuedOutside && given[name] == "" { out[name] = true } } return out } // 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] }