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:
@@ -442,6 +442,10 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
// And the machine underneath, which no binding of its own can tell it.
|
||||
thisMachine := machineFacts(r)
|
||||
|
||||
// Which of this module's files carry a secret, for the rule that a container may not read
|
||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||
secretFiles := secretFilesOf(resources)
|
||||
|
||||
for _, unsettled := range resources {
|
||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
@@ -451,6 +455,12 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
for k, v := range resource {
|
||||
copied[k] = v
|
||||
}
|
||||
if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
||||
// such field, and the reason is for a reader of the manifest.
|
||||
delete(copied, SecretsInEnvironment)
|
||||
// **After settings, and that is the whole reason it is here.** A module's file
|
||||
// content is where a setting lands, so a placeholder may only exist once the setting
|
||||
// has been put in — filling secrets first would look at content that is not yet what
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aModuleWithAnEnvFileSecret(exception string) Manifest {
|
||||
container := map[string]any{
|
||||
"id": "server", "type": "container", "name": "app", "image": "app@sha256:" + strings.Repeat("a", 64),
|
||||
"env-file": []any{"/var/lib/app/server.env"},
|
||||
}
|
||||
if exception != "" {
|
||||
container[SecretsInEnvironment] = exception
|
||||
}
|
||||
return Manifest{
|
||||
Module: "app", Version: "1",
|
||||
OwnSecrets: map[string]string{"token": "/var/lib/app/token.secret"},
|
||||
Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600",
|
||||
"content": "APP_TOKEN=${secret:token}\n"},
|
||||
container,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func declare(t *testing.T, m Manifest) ([]map[string]any, error) {
|
||||
t.Helper()
|
||||
got, err := Resolve(shelf(m), []string{m.Module},
|
||||
Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got.Declaration(Rendering{Needed: map[string]map[string]string{"app": {"token": "SEALED"}}})
|
||||
}
|
||||
|
||||
// A container that reads a file carrying a secret as its environment is refused, unless it says
|
||||
// why — and then the reason stays in the catalogue and never reaches the machine.
|
||||
func TestASecretInAnEnvFileIsRefusedUnlessDeclared(t *testing.T) {
|
||||
_, err := declare(t, aModuleWithAnEnvFileSecret(""))
|
||||
if err == nil || !strings.Contains(err.Error(), "04-ISSUES/041") {
|
||||
t.Fatalf("an env-file carrying a secret was declared without a word: %v", err)
|
||||
}
|
||||
|
||||
out, err := declare(t, aModuleWithAnEnvFileSecret("the image reads its configuration from the environment only"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if _, leaked := r[SecretsInEnvironment]; leaked {
|
||||
t.Fatalf("the catalogue's reason was sent to the machine: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A secret placeholder inside a container's env is refused outright: nothing fills it there.
|
||||
func TestASecretInEnvIsAlwaysRefused(t *testing.T) {
|
||||
m := aModuleWithAnEnvFileSecret("said")
|
||||
m.Resources[1]["env"] = map[string]any{"APP_TOKEN": "${secret:token}"}
|
||||
if _, err := declare(t, m); err == nil || !strings.Contains(err.Error(), "never filled into an environment variable") {
|
||||
t.Fatalf("a secret in env was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A file with no secret in it is an ordinary env-file and needs no word.
|
||||
func TestAnEnvFileWithoutASecretNeedsNothing(t *testing.T) {
|
||||
m := aModuleWithAnEnvFileSecret("")
|
||||
m.Resources[0]["content"] = "APP_MODE=production\n"
|
||||
if _, err := declare(t, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user