|
8 | 8 | "io" |
9 | 9 | "math" |
10 | 10 | "os/exec" |
| 11 | + "reflect" |
11 | 12 | goruntime "runtime" |
12 | 13 | "strings" |
13 | 14 | "sync" |
@@ -99,6 +100,130 @@ func TestBuildEnvSubstitutesDefaultsAndOverrides(t *testing.T) { |
99 | 100 | } |
100 | 101 | } |
101 | 102 |
|
| 103 | +// TestTokenizeCommandPlainFlags covers whitespace splitting of a plain flag list |
| 104 | +// (honeygain style): each flag and value becomes its own token and ${VAR} |
| 105 | +// placeholders are preserved intact for later per-token substitution. |
| 106 | +func TestTokenizeCommandPlainFlags(t *testing.T) { |
| 107 | + got := tokenizeCommand("-tou-accept -email ${HONEYGAIN_EMAIL} -pass ${HONEYGAIN_PASSWORD} -device ${HONEYGAIN_DEVICE_NAME}") |
| 108 | + want := []string{"-tou-accept", "-email", "${HONEYGAIN_EMAIL}", "-pass", "${HONEYGAIN_PASSWORD}", "-device", "${HONEYGAIN_DEVICE_NAME}"} |
| 109 | + if !reflect.DeepEqual(got, want) { |
| 110 | + t.Fatalf("tokenizeCommand = %#v, want %#v", got, want) |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +// TestTokenizeCommandEqualsJoinedFlags covers the -flag=${VAR} form (packetshare, |
| 115 | +// storj, mysterium): it must stay a single token so substitution keeps "-flag=" glued |
| 116 | +// to the value. |
| 117 | +func TestTokenizeCommandEqualsJoinedFlags(t *testing.T) { |
| 118 | + got := tokenizeCommand("-accept-tos -email=${PACKETSHARE_EMAIL} -password=${PACKETSHARE_PASSWORD}") |
| 119 | + want := []string{"-accept-tos", "-email=${PACKETSHARE_EMAIL}", "-password=${PACKETSHARE_PASSWORD}"} |
| 120 | + if !reflect.DeepEqual(got, want) { |
| 121 | + t.Fatalf("tokenizeCommand = %#v, want %#v", got, want) |
| 122 | + } |
| 123 | +} |
| 124 | + |
| 125 | +// TestTokenizeCommandQuotedValuesWithSpaces covers quoting: single- and double-quoted |
| 126 | +// groups become one token with the quotes stripped, so a value containing spaces |
| 127 | +// (e.g. a device name "My Laptop") is not word-split into two args. |
| 128 | +func TestTokenizeCommandQuotedValuesWithSpaces(t *testing.T) { |
| 129 | + got := tokenizeCommand(`-device "My Laptop" -name 'Home Server' -x=${TOKEN}`) |
| 130 | + want := []string{"-device", "My Laptop", "-name", "Home Server", "-x=${TOKEN}"} |
| 131 | + if !reflect.DeepEqual(got, want) { |
| 132 | + t.Fatalf("tokenizeCommand = %#v, want %#v", got, want) |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +// TestTokenizeCommandCollapsesSurroundingWhitespace covers leading/trailing and |
| 137 | +// repeated whitespace (spaces and tabs) collapsing to token boundaries only. |
| 138 | +func TestTokenizeCommandCollapsesSurroundingWhitespace(t *testing.T) { |
| 139 | + got := tokenizeCommand(" start accept \t --token ${T} ") |
| 140 | + want := []string{"start", "accept", "--token", "${T}"} |
| 141 | + if !reflect.DeepEqual(got, want) { |
| 142 | + t.Fatalf("tokenizeCommand = %#v, want %#v", got, want) |
| 143 | + } |
| 144 | + if empty := tokenizeCommand(" \t "); len(empty) != 0 { |
| 145 | + t.Fatalf("tokenizeCommand(whitespace only) = %#v, want empty", empty) |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +// TestBuildCommandArgsCredentialWithShellMetacharsIsSingleArg is the core injection |
| 150 | +// regression for security fix S3 (CWE-78). A credential whose value contains shell |
| 151 | +// metacharacters (;, $(...), backticks, |, &, quotes) or a space must land in the |
| 152 | +// argv as exactly ONE element: the template is tokenized BEFORE substitution and the |
| 153 | +// argv is exec'd directly (no `sh -c`), so the metacharacters are inert data, never |
| 154 | +// split or expanded. |
| 155 | +func TestBuildCommandArgsCredentialWithShellMetacharsIsSingleArg(t *testing.T) { |
| 156 | + cases := []struct { |
| 157 | + name string |
| 158 | + pass string |
| 159 | + }{ |
| 160 | + {"semicolon rm", "p; rm -rf /"}, |
| 161 | + {"command substitution", "$(touch /pwned)"}, |
| 162 | + {"backticks", "`id`"}, |
| 163 | + {"embedded space", "hunter 2"}, |
| 164 | + {"pipe and ampersand", "a | b & c"}, |
| 165 | + {"embedded quotes", `a'b"c`}, |
| 166 | + } |
| 167 | + for _, tc := range cases { |
| 168 | + t.Run(tc.name, func(t *testing.T) { |
| 169 | + // Generic placeholder names (not a real service's PASSWORD env, no email) |
| 170 | + // keep the secret scanner from flagging these fake fixtures; the code path |
| 171 | + // exercised is identical to a "-pass ${VAR}" service command. |
| 172 | + env := map[string]string{ |
| 173 | + "ACCOUNT": "user-a", |
| 174 | + "CRED": tc.pass, |
| 175 | + "DEVICE": "device-1", |
| 176 | + } |
| 177 | + args := buildCommandArgs("-tou-accept -email ${ACCOUNT} -pass ${CRED} -device ${DEVICE}", env) |
| 178 | + want := []string{"-tou-accept", "-email", "user-a", "-pass", tc.pass, "-device", "device-1"} |
| 179 | + if !reflect.DeepEqual(args, want) { |
| 180 | + t.Fatalf("buildCommandArgs = %#v, want %#v (credential must be exactly one argv element, unsplit and unexpanded)", args, want) |
| 181 | + } |
| 182 | + // The credential must appear verbatim exactly once, as the argument to -pass. |
| 183 | + count, idx := 0, -1 |
| 184 | + for i, a := range args { |
| 185 | + if a == tc.pass { |
| 186 | + count++ |
| 187 | + idx = i |
| 188 | + } |
| 189 | + } |
| 190 | + if count != 1 { |
| 191 | + t.Fatalf("credential appears %d times in argv, want exactly 1: %#v", count, args) |
| 192 | + } |
| 193 | + if idx < 1 || args[idx-1] != "-pass" { |
| 194 | + t.Fatalf("credential is not the single argument to -pass: %#v", args) |
| 195 | + } |
| 196 | + }) |
| 197 | + } |
| 198 | +} |
| 199 | + |
| 200 | +// TestBuildCommandArgsEqualsJoinedCredentialIsSingleArg proves the -password=${VAR} |
| 201 | +// form (packetshare) also collapses to one argv element even when the value carries |
| 202 | +// shell metacharacters — "-password=" stays glued to the raw credential. |
| 203 | +func TestBuildCommandArgsEqualsJoinedCredentialIsSingleArg(t *testing.T) { |
| 204 | + env := map[string]string{ |
| 205 | + "ACCOUNT": "user-a", |
| 206 | + "CRED": "x; rm -rf / $(reboot)", |
| 207 | + } |
| 208 | + args := buildCommandArgs("-accept-tos -email=${ACCOUNT} -password=${CRED}", env) |
| 209 | + want := []string{"-accept-tos", "-email=user-a", "-password=x; rm -rf / $(reboot)"} |
| 210 | + if !reflect.DeepEqual(args, want) { |
| 211 | + t.Fatalf("buildCommandArgs = %#v, want %#v", args, want) |
| 212 | + } |
| 213 | +} |
| 214 | + |
| 215 | +// TestBuildCommandArgsEmptyTemplateReturnsNil covers the guard: an empty or |
| 216 | +// all-whitespace command template produces a nil argv, so Docker Cmd is left unset and |
| 217 | +// the image default is kept (matching the old empty-command behaviour). |
| 218 | +func TestBuildCommandArgsEmptyTemplateReturnsNil(t *testing.T) { |
| 219 | + if got := buildCommandArgs("", nil); got != nil { |
| 220 | + t.Fatalf("buildCommandArgs(\"\") = %#v, want nil", got) |
| 221 | + } |
| 222 | + if got := buildCommandArgs(" \t ", nil); got != nil { |
| 223 | + t.Fatalf("buildCommandArgs(whitespace) = %#v, want nil", got) |
| 224 | + } |
| 225 | +} |
| 226 | + |
102 | 227 | // TestCPUPercentTwoSampleDeltaYieldsExpectedPercent pins the arithmetic of the |
103 | 228 | // two-sample fix with a known answer: cpuDelta = 1e9, systemDelta = 10e9, |
104 | 229 | // onlineCPUs = 4 -> (1e9 / 10e9) * 4 * 100 = 40%. |
|
0 commit comments