03ab121b25
gates / gates (push) Successful in 46s
Security review of 2fe6341 (same night, before any release). Both red-proofed.
124 lines
5.5 KiB
Go
124 lines
5.5 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"])
|
|
}
|
|
// Security review 2026-10-18: with NO drive, an app-env USERDATA_PATH (a deploy field a catalog push can add) is
|
|
// no root either — it could name another app's appdata/. Consequence: such a bind is refused.
|
|
// RED-PROOF: without the delete in policyEnv, the bind into nextcloud's folder is admitted.
|
|
noHDD := map[string]string{"USERDATA_PATH": "/mnt/felhom-drives/a/felhom-data/appdata"}
|
|
if v, ok := m.policyEnv(noHDD)["USERDATA_PATH"]; ok {
|
|
t.Fatalf("an app with no drive kept its own USERDATA_PATH %q as a root", v)
|
|
}
|
|
c := "services:\n web:\n image: x/y:1\n volumes:\n - ${USERDATA_PATH}/nextcloud:/x\n"
|
|
if err := m.admitCompose("web", []byte(c), noHDD); err == nil {
|
|
t.Fatal("a bind into another app's folder through an app-env USERDATA_PATH was admitted")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|