1package domain
2
3import (
4 "fmt"
5 "slices"
6 "strings"
7)
8
9// allowedAppCapabilities is the closed set of Linux capabilities an
10// administrative capability policy may add to an app service. It is the
11// Docker/Podman default container set plus the few extra capabilities an
12// unprivileged network or VM workload legitimately needs:
13//
14// NET_ADMIN configure interfaces, TAP devices, nftables
15// inside the container's own network namespace
16// IPC_LOCK lock guest memory (mlock)
17// SYS_NICE, SYS_RESOURCE scheduling priority and rlimit overrides
18//
19// Everything else is refused. In particular ALL, SYS_ADMIN, SYS_PTRACE,
20// SYS_MODULE, SYS_RAWIO, SYS_BOOT, SYS_TIME, DAC_READ_SEARCH, BPF,
21// PERFMON, MAC_ADMIN, MAC_OVERRIDE, AUDIT_CONTROL, and SYSLOG can reach
22// the host or defeat container confinement and have no policy form: a
23// workload that truly needs one is not an unprivileged service.
24var allowedAppCapabilities = []string{
25 "AUDIT_WRITE",
26 "CHOWN",
27 "DAC_OVERRIDE",
28 "FOWNER",
29 "FSETID",
30 "IPC_LOCK",
31 "KILL",
32 "MKNOD",
33 "NET_ADMIN",
34 "NET_BIND_SERVICE",
35 "NET_RAW",
36 "SETFCAP",
37 "SETGID",
38 "SETPCAP",
39 "SETUID",
40 "SYS_CHROOT",
41 "SYS_NICE",
42 "SYS_RESOURCE",
43}
44
45// defaultAppCapAdd is Gordon's base capability add set for app containers
46// (on top of cap_drop = ALL). Granted capabilities are added to it.
47var defaultAppCapAdd = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
48
49// DefaultAppCapAdd returns Gordon's base capability add set. The slice is a
50// copy.
51func DefaultAppCapAdd() []string {
52 return slices.Clone(defaultAppCapAdd)
53}
54
55// NormalizeCapability returns the canonical form of a capability name as
56// reported by an engine: upper case without the CAP_ prefix.
57func NormalizeCapability(name string) string {
58 return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(name)), "CAP_")
59}
60
61// ExcessCapabilities returns the sorted, normalized capabilities in actual
62// that are not in authorized. Both sides are normalized first, so engines
63// that report CAP_ prefixed names compare equal. A container holding fewer
64// capabilities than authorized is not excess.
65func ExcessCapabilities(actual, authorized []string) []string {
66 allowed := make(map[string]struct{}, len(authorized))
67 for _, name := range authorized {
68 allowed[NormalizeCapability(name)] = struct{}{}
69 }
70 var excess []string
71 for _, name := range actual {
72 n := NormalizeCapability(name)
73 if _, ok := allowed[n]; !ok {
74 excess = append(excess, n)
75 }
76 }
77 slices.Sort(excess)
78 return slices.Compact(excess)
79}
80
81// AllowedAppCapabilities returns the sorted capability names a policy may
82// add. The slice is a copy.
83func AllowedAppCapabilities() []string {
84 return slices.Clone(allowedAppCapabilities)
85}
86
87// AppCapabilityPolicy is one named administrative capability grant
88// declared under [app_capabilities.<name>]. The installation maps a
89// logical name to explicit Linux capabilities and exact app/service
90// allowlists. App manifests request capabilities by logical name; Gordon
91// resolves them at activation time and adds them to the container on top
92// of its default capability set. Resolution is never persisted: revisions
93// keep the logical names.
94type AppCapabilityPolicy struct {
95 Name string
96 // Add holds the explicit capability names granted (for example
97 // "NET_ADMIN"), restricted to the allowed set.
98 Add []string
99 // AllowedApps and AllowedServices are exact, non-empty allowlists.
100 AllowedApps []string
101 AllowedServices []string
102}
103
104// ValidateCapabilityPolicyRef checks a logical capability-policy name used
105// from a manifest (service charset plus -- ban).
106func ValidateCapabilityPolicyRef(name string) error {
107 if !serviceNamePattern.MatchString(name) {
108 return fmt.Errorf("%w: capability name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
109 }
110 if strings.Contains(name, "--") {
111 return fmt.Errorf("%w: capability name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
112 }
113 return nil
114}
115
116// ValidateCapabilityPolicyName checks a stable policy name (service
117// charset plus -- ban).
118func ValidateCapabilityPolicyName(name string) error {
119 if !serviceNamePattern.MatchString(name) {
120 return fmt.Errorf("%w: capability policy name %q must match [a-z0-9_.-], max 63", ErrCapabilityPolicy, name)
121 }
122 if strings.Contains(name, "--") {
123 return fmt.Errorf("%w: capability policy name %q must not contain -- (reserved separator)", ErrCapabilityPolicy, name)
124 }
125 return nil
126}
127
128// Validate checks the policy shape without touching the runtime.
129func (p AppCapabilityPolicy) Validate() error {
130 if err := ValidateCapabilityPolicyName(p.Name); err != nil {
131 return err
132 }
133 if err := validateCapabilityNames(p.Add); err != nil {
134 return fmt.Errorf("%w: capability policy %q: %v", ErrCapabilityPolicy, p.Name, err)
135 }
136 if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
137 return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
138 }
139 if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
140 return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
141 }
142 return nil
143}
144
145// validateCapabilityNames requires a non-empty list of unique names from
146// the allowed set.
147func validateCapabilityNames(names []string) error {
148 if len(names) == 0 {
149 return fmt.Errorf("add must not be empty")
150 }
151 return ValidateAddableCapabilities(names)
152}
153
154// ValidateAddableCapabilities checks that every name is in the allowed set
155// and unique. An empty list is valid. It is also the runtime guard's check
156// on resolved capabilities, whatever produced them.
157func ValidateAddableCapabilities(names []string) error {
158 seen := map[string]struct{}{}
159 for _, name := range names {
160 if name == "" {
161 return fmt.Errorf("capability must not be empty")
162 }
163 if !slices.Contains(allowedAppCapabilities, name) {
164 return fmt.Errorf("capability %q is not allowed (allowed: %s)", name, strings.Join(allowedAppCapabilities, ", "))
165 }
166 if _, ok := seen[name]; ok {
167 return fmt.Errorf("duplicate capability %q", name)
168 }
169 seen[name] = struct{}{}
170 }
171 return nil
172}
173
174// ResolveAppCapabilities resolves the logical grant under this policy for
175// the given app and service. It validates the policy shape, enforces the
176// exact app/service allowlists, and returns the explicit capabilities.
177func (p AppCapabilityPolicy) ResolveAppCapabilities(app, service string) ([]string, error) {
178 if err := p.Validate(); err != nil {
179 return nil, err
180 }
181 if !allowlistContains(p.AllowedApps, app) {
182 return nil, fmt.Errorf("%w: capability policy %q does not allow app %q", ErrCapabilityPolicy, p.Name, app)
183 }
184 if !allowlistContains(p.AllowedServices, service) {
185 return nil, fmt.Errorf("%w: capability policy %q does not allow service %q", ErrCapabilityPolicy, p.Name, service)
186 }
187 return slices.Clone(p.Add), nil
188}
189
190// ResolveAppCapabilities resolves every logical capability policy in names
191// for one app service. It returns the deterministic, de-duplicated union
192// (sorted): two policies granting the same capability is harmless. Errors
193// name only the app, service, and logical policy.
194func ResolveAppCapabilities(app, service string, names []string, policies map[string]AppCapabilityPolicy) ([]string, error) {
195 if len(names) == 0 {
196 return nil, nil
197 }
198 var resolved []string
199 for _, name := range names {
200 policy, ok := policies[name]
201 if !ok {
202 return nil, fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured", ErrCapabilityPolicy, app, service, name)
203 }
204 caps, err := policy.ResolveAppCapabilities(app, service)
205 if err != nil {
206 return nil, fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", ErrCapabilityPolicy, app, service, name)
207 }
208 resolved = append(resolved, caps...)
209 }
210 slices.Sort(resolved)
211 return slices.Compact(resolved), nil
212}