1package domain_test
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/gordon/internal/domain"
12)
13
14const guardContainer = "gordon-blog--web--x"
15
16func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig {
17 return &domain.ContainerConfig{
18 Name: guardContainer,
19 NetworkMode: "gordon-app-1",
20 Labels: map[string]string{domain.LabelApp: "blog"},
21 Binds: binds,
22 }
23}
24
25func TestRuntimeGuard_NetworkModes(t *testing.T) {
26 var guard domain.RuntimeGuard
27 for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} {
28 cfg := guardConfig()
29 cfg.NetworkMode = mode
30 err := guard.Check(cfg)
31 require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode)
32 assert.NotContains(t, err.Error(), "/proc")
33 }
34 require.NoError(t, guard.Check(guardConfig()))
35}
36
37func TestRuntimeGuard_VolumeNames(t *testing.T) {
38 var guard domain.RuntimeGuard
39 for _, name := range []string{"/etc", "a/b", `a\b`} {
40 cfg := guardConfig()
41 cfg.Volumes = map[string]string{"/data": name}
42 require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
43
44 cfg = guardConfig()
45 cfg.ReadOnlyVolumes = map[string]string{"/data": name}
46 require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
47 }
48 cfg := guardConfig()
49 cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"}
50 require.NoError(t, guard.Check(cfg))
51}
52
53func TestRuntimeGuard_ProtectedBinds(t *testing.T) {
54 base := t.TempDir()
55 dataDir := filepath.Join(base, "data")
56 protectedChild := filepath.Join(dataDir, "registry")
57 require.NoError(t, os.MkdirAll(protectedChild, 0o700))
58 unrelated := filepath.Join(base, "public")
59 require.NoError(t, os.MkdirAll(unrelated, 0o700))
60 link := filepath.Join(base, "link")
61 require.NoError(t, os.Symlink(dataDir, link))
62
63 guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}}
64 cases := map[string]string{
65 "equal": dataDir,
66 "under": protectedChild,
67 "ancestor": base,
68 "symlink": link,
69 }
70 for name, source := range cases {
71 t.Run(name, func(t *testing.T) {
72 err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true}))
73 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
74 assert.Contains(t, err.Error(), `"conf"`)
75 assert.NotContains(t, err.Error(), base)
76 })
77 }
78 require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true})))
79}
80
81func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
82 dataDir := t.TempDir()
83 secretDir := filepath.Join(dataDir, "secrets-run")
84 guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir}
85 own := domain.ContainerBind{
86 Name: "secret-file-DB",
87 Source: filepath.Join(secretDir, "blog", guardContainer, "DB"),
88 Destination: "/run/secrets/DB",
89 ReadOnly: true,
90 }
91 require.NoError(t, guard.Check(guardConfig(own)))
92
93 cases := map[string]func(*domain.ContainerConfig){
94 "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false },
95 "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" },
96 "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" },
97 "no app label": func(c *domain.ContainerConfig) { c.Labels = nil },
98 "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" },
99 "extra segment": func(c *domain.ContainerConfig) {
100 c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB")
101 },
102 "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) },
103 "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir },
104 }
105 for name, mutate := range cases {
106 t.Run(name, func(t *testing.T) {
107 cfg := guardConfig(own)
108 mutate(cfg)
109 err := guard.Check(cfg)
110 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
111 assert.NotContains(t, err.Error(), dataDir)
112 })
113 }
114}
115
116func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
117 dataDir := t.TempDir()
118 logs := filepath.Join(dataDir, "logs")
119 require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120 keys := filepath.Join(dataDir, "logs", "keys")
121 secrets := filepath.Join(dataDir, "secrets-run")
122 require.NoError(t, os.MkdirAll(secrets, 0o700))
123 escape := filepath.Join(logs, "escape")
124 require.NoError(t, os.Symlink(secrets, escape))
125 guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
126 bind := func(source string, readOnly bool) *domain.ContainerConfig {
127 return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
128 }
129
130 require.NoError(t, guard.Check(bind(logs, true)))
131 require.NoError(t, guard.Check(bind(filepath.Join(logs, "containers"), true)))
132
133 withProtected := domain.RuntimeGuard{ProtectedPaths: []string{dataDir, keys}, LogDirs: []string{logs}}
134 refused := map[string]struct {
135 guard domain.RuntimeGuard
136 cfg *domain.ContainerConfig
137 }{
138 "writable log dir": {guard, bind(logs, false)},
139 "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
140 "data dir read-only": {guard, bind(dataDir, true)},
141 "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
142 "symlink out of logs": {guard, bind(escape, true)},
143 "log dir with protected": {withProtected, bind(logs, true)},
144 }
145 for name, tc := range refused {
146 t.Run(name, func(t *testing.T) {
147 require.ErrorIs(t, tc.guard.Check(tc.cfg), domain.ErrRuntimeGuard)
148 })
149 }
150}