|
| 1 | +// Copyright (c) 2025-2026 Netresearch DTT GmbH |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +package cli |
| 5 | + |
| 6 | +import ( |
| 7 | + "testing" |
| 8 | + |
| 9 | + "github.com/stretchr/testify/assert" |
| 10 | + "github.com/stretchr/testify/require" |
| 11 | +) |
| 12 | + |
| 13 | +// This file pins the fix for GHSA-h7m7-v83x-vfp3: privilege-bearing job |
| 14 | +// keys sourced from container labels (privileged, env-file, env-from) must |
| 15 | +// be stripped when AllowHostJobsFromLabels=false, exactly as the volume / |
| 16 | +// volumes-from vectors from #462 already are. Unlike those (which drop the |
| 17 | +// whole job), these keys are stripped in place so the job still runs — just |
| 18 | +// unprivileged and without host / cross-container environment injection. |
| 19 | +// |
| 20 | +// Pre-fix, filterJobsWithHostEscalation covered only job-run / |
| 21 | +// job-service-run and only inspected volume / volumes-from, so: |
| 22 | +// - a job-exec with privileged=true reached `docker exec --privileged`; |
| 23 | +// - env-file / env-from leaked host files and sibling-container |
| 24 | +// environments on every job type, including the two the filter did run |
| 25 | +// on. |
| 26 | +// These tests fail on the pre-fix tree (the keys survive) and pass once the |
| 27 | +// strip is wired into buildFromDockerContainers. |
| 28 | + |
| 29 | +// baseExecJobLabels returns the minimum labels needed to enable a job-exec |
| 30 | +// on the (running) attacker container itself. job-exec runs on non-service |
| 31 | +// containers, so no ofelia.service label is required. |
| 32 | +func baseExecJobLabels(name string) map[string]string { |
| 33 | + return map[string]string{ |
| 34 | + "ofelia.enabled": "true", |
| 35 | + "ofelia.job-exec." + name + ".schedule": "@daily", |
| 36 | + "ofelia.job-exec." + name + ".command": "echo ok", |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +// theExecJob returns the single parsed exec job. job-exec names are scoped |
| 41 | +// per [global] job-exec-label-scope, so the map key is not the bare job |
| 42 | +// name; the tests declare exactly one, so read it back by iteration. |
| 43 | +func theExecJob(t *testing.T, c *Config) *ExecJobConfig { |
| 44 | + t.Helper() |
| 45 | + require.Len(t, c.ExecJobs, 1, "expected exactly one parsed exec job") |
| 46 | + for _, j := range c.ExecJobs { |
| 47 | + return j |
| 48 | + } |
| 49 | + return nil |
| 50 | +} |
| 51 | + |
| 52 | +// TestLabelPolicyStripsPrivilegedFromExecJob is the primary GHSA vector: |
| 53 | +// a self-labeling container declares a privileged job-exec, and with the |
| 54 | +// default policy (AllowHostJobsFromLabels=false) the privileged bit must |
| 55 | +// not survive into the parsed job — otherwise it reaches |
| 56 | +// `docker exec --privileged` and enables a container escape. |
| 57 | +func TestLabelPolicyStripsPrivilegedFromExecJob(t *testing.T) { |
| 58 | + t.Parallel() |
| 59 | + labels := baseExecJobLabels("pwn") |
| 60 | + labels["ofelia.job-exec.pwn.privileged"] = "true" |
| 61 | + |
| 62 | + c, handler := runHostJobPolicy(t, false, labels) |
| 63 | + |
| 64 | + job := theExecJob(t, c) |
| 65 | + assert.False(t, job.Privileged, |
| 66 | + "privileged=true from a container label must be stripped when AllowHostJobsFromLabels=false (GHSA-h7m7-v83x-vfp3)") |
| 67 | + assert.True(t, handler.HasError("SECURITY POLICY VIOLATION"), |
| 68 | + "stripping a privilege-bearing key must log a SECURITY POLICY VIOLATION for operator triage") |
| 69 | + assert.True(t, handler.HasError("job-exec"), |
| 70 | + "violation log must name the job-exec type") |
| 71 | + assert.True(t, handler.HasError("pwn"), |
| 72 | + "violation log must name the job for operator triage") |
| 73 | +} |
| 74 | + |
| 75 | +// TestLabelPolicyStripsEnvFileFromExecJob pins the env-file file-disclosure |
| 76 | +// vector on job-exec: env-file reads a path in ofelia's filesystem view and |
| 77 | +// injects it as the job env, so a label-sourced env-file must be stripped. |
| 78 | +func TestLabelPolicyStripsEnvFileFromExecJob(t *testing.T) { |
| 79 | + t.Parallel() |
| 80 | + labels := baseExecJobLabels("leak") |
| 81 | + labels["ofelia.job-exec.leak.env-file"] = "/root/.aws/credentials" |
| 82 | + |
| 83 | + c, handler := runHostJobPolicy(t, false, labels) |
| 84 | + |
| 85 | + job := theExecJob(t, c) |
| 86 | + assert.Empty(t, job.EnvFile, |
| 87 | + "env-file from a container label must be stripped when AllowHostJobsFromLabels=false — it reads files from ofelia's filesystem view (GHSA-h7m7-v83x-vfp3)") |
| 88 | + assert.True(t, handler.HasError("SECURITY POLICY VIOLATION"), |
| 89 | + "stripping env-file must log a SECURITY POLICY VIOLATION") |
| 90 | + assert.True(t, handler.HasError("env-file"), |
| 91 | + "violation log must name the env-file vector for operator triage") |
| 92 | +} |
| 93 | + |
| 94 | +// TestLabelPolicyStripsEnvFromFromExecJob pins the env-from cross-container |
| 95 | +// secret-theft vector on job-exec: env-from copies another container's |
| 96 | +// entire environment, so a label-sourced env-from must be stripped. |
| 97 | +func TestLabelPolicyStripsEnvFromFromExecJob(t *testing.T) { |
| 98 | + t.Parallel() |
| 99 | + labels := baseExecJobLabels("steal") |
| 100 | + labels["ofelia.job-exec.steal.env-from"] = `["victim-container"]` |
| 101 | + |
| 102 | + c, handler := runHostJobPolicy(t, false, labels) |
| 103 | + |
| 104 | + job := theExecJob(t, c) |
| 105 | + assert.Empty(t, job.EnvFrom, |
| 106 | + "env-from from a container label must be stripped when AllowHostJobsFromLabels=false — it copies a sibling container's whole environment (GHSA-h7m7-v83x-vfp3)") |
| 107 | + assert.True(t, handler.HasError("env-from"), |
| 108 | + "violation log must name the env-from vector for operator triage") |
| 109 | +} |
| 110 | + |
| 111 | +// TestLabelPolicyStripsEnvFileFromRunJob confirms env-file is stripped on |
| 112 | +// job-run too. Pre-fix, job-run passed through filterJobsWithHostEscalation |
| 113 | +// but that filter only inspected volume / volumes-from, so env-file leaked |
| 114 | +// despite the job being "covered". |
| 115 | +func TestLabelPolicyStripsEnvFileFromRunJob(t *testing.T) { |
| 116 | + t.Parallel() |
| 117 | + labels := baseRunJobLabels("run-leak") |
| 118 | + labels["ofelia.job-run.run-leak.env-file"] = "/etc/ofelia-secrets.env" |
| 119 | + |
| 120 | + c, handler := runHostJobPolicy(t, false, labels) |
| 121 | + |
| 122 | + require.Contains(t, c.RunJobs, "run-leak", |
| 123 | + "the job itself has no volume/volumes-from vector, so it must survive — only the env-file key is stripped") |
| 124 | + assert.Empty(t, c.RunJobs["run-leak"].EnvFile, |
| 125 | + "env-file from a container label must be stripped on job-run when AllowHostJobsFromLabels=false (GHSA-h7m7-v83x-vfp3)") |
| 126 | + assert.True(t, handler.HasError("env-file"), |
| 127 | + "violation log must name the env-file vector") |
| 128 | +} |
| 129 | + |
| 130 | +// TestLabelPolicyStripsEnvFromFromServiceJob confirms env-from is stripped |
| 131 | +// on job-service-run too. |
| 132 | +func TestLabelPolicyStripsEnvFromFromServiceJob(t *testing.T) { |
| 133 | + t.Parallel() |
| 134 | + labels := map[string]string{ |
| 135 | + "ofelia.enabled": "true", |
| 136 | + "ofelia.service": "true", |
| 137 | + "ofelia.job-service-run.svc-leak.schedule": "@daily", |
| 138 | + "ofelia.job-service-run.svc-leak.image": "alpine", |
| 139 | + "ofelia.job-service-run.svc-leak.command": "env", |
| 140 | + "ofelia.job-service-run.svc-leak.env-from": `["victim-container"]`, |
| 141 | + } |
| 142 | + |
| 143 | + c, handler := runHostJobPolicy(t, false, labels) |
| 144 | + |
| 145 | + require.Contains(t, c.ServiceJobs, "svc-leak", |
| 146 | + "the service job has no host-mount vector, so it survives — only env-from is stripped") |
| 147 | + assert.Empty(t, c.ServiceJobs["svc-leak"].EnvFrom, |
| 148 | + "env-from from a container label must be stripped on job-service-run when AllowHostJobsFromLabels=false (GHSA-h7m7-v83x-vfp3)") |
| 149 | + assert.True(t, handler.HasError("env-from"), |
| 150 | + "violation log must name the env-from vector") |
| 151 | +} |
| 152 | + |
| 153 | +// TestLabelPolicyStripsPrivilegedCaseInsensitive pins that the strip |
| 154 | +// matches every casing / separator variant the decoder would honor. The |
| 155 | +// mapstructure decoder matches keys via normalizeKey (lowercase, strip |
| 156 | +// - and _), so `Privileged`, `ENV_FILE`, etc. all decode into the fields; |
| 157 | +// a naive delete(job, "privileged") would miss them and leave the bypass |
| 158 | +// open. env_from -> normalizes to envfrom -> matches EnvFrom. |
| 159 | +func TestLabelPolicyStripsPrivilegedCaseInsensitive(t *testing.T) { |
| 160 | + t.Parallel() |
| 161 | + labels := baseExecJobLabels("mixed-case") |
| 162 | + labels["ofelia.job-exec.mixed-case.Privileged"] = "true" |
| 163 | + labels["ofelia.job-exec.mixed-case.ENV_FILE"] = "/root/.aws/credentials" |
| 164 | + |
| 165 | + c, _ := runHostJobPolicy(t, false, labels) |
| 166 | + |
| 167 | + job := theExecJob(t, c) |
| 168 | + assert.False(t, job.Privileged, |
| 169 | + "a mixed-case Privileged label must still be stripped — the decoder matches it case-insensitively, so the strip must too") |
| 170 | + assert.Empty(t, job.EnvFile, |
| 171 | + "an ENV_FILE label (underscore variant) must still be stripped — normalizeKey collapses it to the same field") |
| 172 | +} |
| 173 | + |
| 174 | +// TestLabelPolicyHonorsPrivilegedWhenAllowed is the inverse contract: with |
| 175 | +// AllowHostJobsFromLabels=true the operator has opted in (trusted, |
| 176 | +// single-tenant), so privileged / env-file / env-from are honored unchanged |
| 177 | +// — the strip must not over-block. |
| 178 | +func TestLabelPolicyHonorsPrivilegedWhenAllowed(t *testing.T) { |
| 179 | + t.Parallel() |
| 180 | + labels := baseExecJobLabels("trusted") |
| 181 | + labels["ofelia.job-exec.trusted.privileged"] = "true" |
| 182 | + labels["ofelia.job-exec.trusted.env-file"] = "/etc/app.env" |
| 183 | + |
| 184 | + c, _ := runHostJobPolicy(t, true, labels) |
| 185 | + |
| 186 | + job := theExecJob(t, c) |
| 187 | + assert.True(t, job.Privileged, |
| 188 | + "AllowHostJobsFromLabels=true must honor privileged from labels (operator opt-in)") |
| 189 | + assert.Equal(t, []string{"/etc/app.env"}, job.EnvFile, |
| 190 | + "AllowHostJobsFromLabels=true must honor env-file from labels (operator opt-in)") |
| 191 | +} |
| 192 | + |
| 193 | +// TestLabelPolicyKeepsCleanExecJob confirms the strip does not over-block: |
| 194 | +// a job-exec with no privilege-bearing keys survives untouched and logs no |
| 195 | +// violation. |
| 196 | +func TestLabelPolicyKeepsCleanExecJob(t *testing.T) { |
| 197 | + t.Parallel() |
| 198 | + c, handler := runHostJobPolicy(t, false, baseExecJobLabels("clean")) |
| 199 | + |
| 200 | + job := theExecJob(t, c) |
| 201 | + assert.False(t, job.Privileged, "a clean exec job has no privileged bit") |
| 202 | + assert.Empty(t, job.EnvFile, "a clean exec job has no env-file") |
| 203 | + assert.Empty(t, job.EnvFrom, "a clean exec job has no env-from") |
| 204 | + assert.False(t, handler.HasError("SECURITY POLICY VIOLATION"), |
| 205 | + "a job with no escalation vectors must not trigger the policy") |
| 206 | +} |
0 commit comments