A secret reaches a process as a file (ADR 0086)

The broker settings take a _FILE twin like the store connections; the
catalogue engine refuses a secret placeholder in a container's env and a
secret-carrying env-file unless the container says why with
secrets-in-environment, which stays in the catalogue and never reaches the
machine.
This commit is contained in:
2026-09-21 10:10:33 +02:00
parent 6b695c82d7
commit 4531f2244f
8 changed files with 245 additions and 5 deletions
+65
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"regexp"
"sort"
"strings"
)
// A credential and a configuration file meeting.
@@ -83,6 +84,70 @@ func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, e
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: \"<why>\"",
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