6d90374f63
gates / gates (push) Successful in 45s
internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
113 lines
4.8 KiB
Go
113 lines
4.8 KiB
Go
package stacks
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
)
|
|
|
|
func allowlistMgr(t *testing.T) *Manager {
|
|
t.Helper()
|
|
m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), sysDataPath: "/mnt/sys_drive", stacks: map[string]*Stack{}}
|
|
return m
|
|
}
|
|
|
|
const refusedCompose = "services:\n web:\n image: x/y:1\n privileged: true\n"
|
|
|
|
// One operator event per DISTINCT refused definition — the same definition on the next 15-minute cycle raises none;
|
|
// a different one raises again; an admitted definition clears the page line. RED-PROOF: with the sha comparison
|
|
// removed, the second identical refusal raises a second event and this test fails.
|
|
func TestAllowList_OneEventPerRefusedDefinition(t *testing.T) {
|
|
m := allowlistMgr(t)
|
|
var events []string
|
|
m.SetDefinitionRefusedHook(func(name, detail string) { events = append(events, name+"|"+detail) })
|
|
env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data"}
|
|
for i := 0; i < 3; i++ {
|
|
if err := m.admitCompose("web", []byte(refusedCompose), env); err == nil {
|
|
t.Fatal("privileged was admitted")
|
|
}
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("events = %d, want 1 for one definition seen three times: %v", len(events), events)
|
|
}
|
|
if !strings.Contains(events[0], "privileged true") {
|
|
t.Fatalf("the event does not name the refused setting: %v", events)
|
|
}
|
|
if m.definitionRefused("web") == "" {
|
|
t.Fatal("the app page line is empty while a refusal stands")
|
|
}
|
|
if err := m.admitCompose("web", []byte(strings.Replace(refusedCompose, "privileged: true", "cap_add: [NET_ADMIN]", 1)), env); err == nil {
|
|
t.Fatal("cap_add was admitted")
|
|
}
|
|
if len(events) != 2 {
|
|
t.Fatalf("a different refused definition raised %d events in all, want 2", len(events))
|
|
}
|
|
if err := m.admitCompose("web", []byte("services:\n web:\n image: x/y:2\n"), env); err != nil {
|
|
t.Fatalf("a clean definition was refused: %v", err)
|
|
}
|
|
if m.definitionRefused("web") != "" {
|
|
t.Fatal("an admitted definition did not clear the page line")
|
|
}
|
|
}
|
|
|
|
// A refusal before the hook is wired (a sync at start-up) still raises the event at the first wired sight.
|
|
func TestAllowList_UnwiredFirstSightIsNotSwallowed(t *testing.T) {
|
|
m := allowlistMgr(t)
|
|
_ = m.admitCompose("web", []byte(refusedCompose), nil)
|
|
n := 0
|
|
m.SetDefinitionRefusedHook(func(string, string) { n++ })
|
|
_ = m.admitCompose("web", []byte(refusedCompose), nil)
|
|
if n != 1 {
|
|
t.Fatalf("events after wiring = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// The refusal text (logged, mailed to the operator) never carries an env VALUE: a secret in a privileged key or a
|
|
// non-root bind variable is shown as the template text.
|
|
func TestAllowList_RefusalNeverPrintsASecret(t *testing.T) {
|
|
m := allowlistMgr(t)
|
|
const secret = "S3cr3t-value-never-shown"
|
|
env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "DB_PASSWORD": secret}
|
|
compose := "services:\n web:\n image: x/y:1\n sysctls:\n net.x: ${DB_PASSWORD}\n volumes:\n - ${DB_PASSWORD}:/x\n env_file: /etc/${DB_PASSWORD}\n"
|
|
err := m.admitCompose("web", []byte(compose), env)
|
|
if err == nil {
|
|
t.Fatal("admitted")
|
|
}
|
|
if strings.Contains(err.Error(), secret) || strings.Contains(m.definitionRefused("web"), secret) {
|
|
t.Fatalf("the refusal printed a secret value: %v", err)
|
|
}
|
|
}
|
|
|
|
// The env the check sees is the env compose sees: the controller-derived USERDATA_PATH wins over an app value.
|
|
func TestAllowList_PolicyEnvDerivesTheRootsLikeComposeExec(t *testing.T) {
|
|
m := allowlistMgr(t)
|
|
e := m.policyEnv(map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "USERDATA_PATH": "/"})
|
|
if e["USERDATA_PATH"] == "/" || !strings.HasPrefix(e["USERDATA_PATH"], "/mnt/felhom-drives/a/") {
|
|
t.Fatalf("USERDATA_PATH = %q — the app's own value must not become a data root", e["USERDATA_PATH"])
|
|
}
|
|
}
|
|
|
|
// The install / settings / restore refusal is a bundle key: the reader gets it in their language, with the app and
|
|
// the refused setting named, and the sentence says nothing changed.
|
|
func TestDefinitionRefused_MessagesFollowTheReader(t *testing.T) {
|
|
err := util.MsgError("err.stacks.definition_refused", "homepage", "homepage: privileged true")
|
|
msg, ok := util.AsMsg(err)
|
|
if !ok {
|
|
t.Fatal("not a message error")
|
|
}
|
|
for lang, want := range map[string]string{"hu": "Semmi nem valtozott", "en": "Nothing was changed"} {
|
|
got := msg.Text(lang)
|
|
ascii := strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(got)
|
|
if !strings.Contains(ascii, want) || !strings.Contains(got, "homepage: privileged true") {
|
|
t.Errorf("[%s] %q", lang, got)
|
|
}
|
|
}
|
|
if msg.Text("hu") == msg.Text("en") {
|
|
t.Fatal("the two languages render the same text — a key is missing from one bundle")
|
|
}
|
|
}
|