-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathappcontainer_test.go
More file actions
495 lines (467 loc) · 17.2 KB
/
Copy pathappcontainer_test.go
File metadata and controls
495 lines (467 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
package isobox
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func testExecutable(t *testing.T) string {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return exe
}
func hasACSID(sids []acCapabilitySID, want acCapabilitySID) bool {
for _, sid := range sids {
if sid == want {
return true
}
}
return false
}
func hasString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
func caveatContains(caveats []string, want string) bool {
for _, caveat := range caveats {
if strings.Contains(caveat, want) {
return true
}
}
return false
}
func TestAppContainerNetworkCapabilities(t *testing.T) {
exe := testExecutable(t)
readable := t.TempDir()
cases := []struct {
name string
net NetMode
want []acCapabilitySID
missing []acCapabilitySID
caveat string
}{
{
name: "disable",
net: NetDisable,
caveat: "blocks loopback",
missing: []acCapabilitySID{
acWinCapabilityInternetClientSid,
acWinCapabilityInternetClientServerSid,
acWinCapabilityPrivateNetworkClientServerSid,
},
},
{
name: "enable",
net: NetEnable,
want: []acCapabilitySID{
acWinCapabilityInternetClientServerSid,
acWinCapabilityPrivateNetworkClientServerSid,
},
missing: []acCapabilitySID{acWinCapabilityInternetClientSid},
},
{
name: "outbound",
net: NetOutbound,
want: []acCapabilitySID{acWinCapabilityInternetClientSid},
missing: []acCapabilitySID{acWinCapabilityInternetClientServerSid, acWinCapabilityPrivateNetworkClientServerSid},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p, err := compileAppContainer(Spec{Args: []string{exe}, Net: tc.net, Readable: []string{readable}})
if err != nil {
t.Fatal(err)
}
for _, sid := range tc.want {
if !hasACSID(p.ac.CapabilitySIDs, sid) {
t.Errorf("missing SID %s in %v", sid, p.ac.CapabilitySIDs)
}
}
for _, sid := range tc.missing {
if hasACSID(p.ac.CapabilitySIDs, sid) {
t.Errorf("unexpected SID %s in %v", sid, p.ac.CapabilitySIDs)
}
}
if !p.ac.LPAC || !profileHas(p.Profile, "all application packages: opt-out") {
t.Fatalf("AppContainer plans must opt out of ALL APPLICATION PACKAGES via LPAC:\n%s", p.Profile)
}
if tc.net == NetDisable && !p.Uses.Has(CapIPCRestrict) {
t.Fatalf("NetDisable AppContainer plan should claim ipc.restrict: %v", p.Uses.List())
}
if tc.net != NetDisable && p.Uses.Has(CapIPCRestrict) {
t.Fatalf("AppContainer plan with network capability SIDs must not claim ipc.restrict: %v", p.Uses.List())
}
if tc.net == NetOutbound {
if !p.Uses.Has(CapNetOutbound) {
t.Fatalf("outbound AppContainer plan should claim %s: %v", CapNetOutbound, p.Uses.List())
}
if !caveatContains(p.Caveats, "InternetClient") {
t.Fatalf("outbound must explain InternetClient limitation: %v", p.Caveats)
}
if !caveatContains(p.Caveats, "not an egress filter") {
t.Fatalf("outbound must explain unrestricted egress/exfiltration risk: %v", p.Caveats)
}
}
if tc.caveat != "" && !caveatContains(p.Caveats, tc.caveat) {
t.Fatalf("missing caveat %q in %v", tc.caveat, p.Caveats)
}
})
}
}
func TestAppContainerReadGrants(t *testing.T) {
exe := testExecutable(t)
readable := t.TempDir()
scoped, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, Readable: []string{readable}})
if err != nil {
t.Fatal(err)
}
if !scoped.Uses.Has(CapFSReadScope) || scoped.Uses.Has(CapFSReadHost) {
t.Fatalf("scoped read capabilities wrong: %v", scoped.Uses.List())
}
if !hasString(scoped.ac.ReadGrants, canonPath(readable)) {
t.Fatalf("missing readable grant %s in %v", canonPath(readable), scoped.ac.ReadGrants)
}
if scoped.ac.WorkDir != "" {
t.Fatalf("empty Dir with scoped reads should not invent a workdir: %+v", scoped.ac)
}
if !hasString(scoped.ac.ReadGrants, scoped.ac.Exe) || hasString(scoped.ac.ReadGrants, canonPath(os.TempDir())) {
t.Fatalf("scoped read grants must include exe without unrelated workdir grants: %+v", scoped.ac)
}
if caveatContains(scoped.Caveats, "broad host reads") {
t.Fatalf("scoped reads should not warn about broad reads: %v", scoped.Caveats)
}
coveredDir, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, Dir: readable, Readable: []string{readable}})
if err != nil {
t.Fatal(err)
}
if coveredDir.ac.WorkDir != canonPath(readable) || !hasString(coveredDir.ac.ReadGrants, canonPath(readable)) {
t.Fatalf("covered workdir should be granted once by its readable scope: %+v", coveredDir.ac)
}
uncoveredDir := t.TempDir()
if _, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, Dir: uncoveredDir, Readable: []string{readable}}); err == nil {
t.Fatal("uncovered scoped workdir should be rejected before AppContainer grants are compiled")
}
writableDir := t.TempDir()
coveredByWritable, err := compileAppContainer(Spec{
Args: []string{exe},
Net: NetEnable,
Dir: writableDir,
Readable: []string{readable},
Write: WriteScope,
Writable: []string{writableDir},
})
if err != nil {
t.Fatal(err)
}
if !hasString(coveredByWritable.ac.ReadGrants, canonPath(writableDir)) {
t.Fatalf("workdir covered by writable scope should receive launch read grant: %+v", coveredByWritable.ac)
}
broad, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable})
if err != nil {
t.Fatal(err)
}
if broad.Uses.Has(CapFSReadHost) || broad.Uses.Has(CapFSReadScope) {
t.Fatalf("empty readable AppContainer capabilities wrong: %v", broad.Uses.List())
}
if !hasString(broad.ac.ReadGrants, broad.ac.Exe) || !hasString(broad.ac.ReadGrants, broad.ac.WorkDir) {
t.Fatalf("empty readable should keep only launch grants: %+v", broad.ac)
}
if !caveatContains(broad.Caveats, "broad host reads are not provided") {
t.Fatalf("empty readable must carry caveat: %v", broad.Caveats)
}
}
func TestAppContainerWriteModes(t *testing.T) {
exe := testExecutable(t)
readable := t.TempDir()
writable := t.TempDir()
scoped, err := compileAppContainer(Spec{
Args: []string{exe},
Net: NetEnable,
Readable: []string{readable},
Write: WriteScope,
Writable: []string{writable},
})
if err != nil {
t.Fatal(err)
}
if !scoped.Uses.Has(CapFSWriteScope) || scoped.Uses.Has(CapFSWriteEphemeral) {
t.Fatalf("scoped write capabilities wrong: %v", scoped.Uses.List())
}
if !hasString(scoped.ac.WriteGrants, canonPath(writable)) {
t.Fatalf("missing writable grant %s in %v", canonPath(writable), scoped.ac.WriteGrants)
}
if !scoped.ac.DeriveOnlyLowbox {
t.Fatalf("WriteScope must avoid per-profile storage: %+v", scoped.ac)
}
if !profileHas(scoped.Profile, "profile storage: derive-only") {
t.Fatalf("WriteScope profile must show derive-only storage:\n%s", scoped.Profile)
}
if !caveatContains(scoped.Caveats, "ambient write access") {
t.Fatalf("WriteScope must caveat ambient writable ACLs: %v", scoped.Caveats)
}
none, err := compileAppContainer(Spec{
Args: []string{exe},
Net: NetEnable,
Readable: []string{readable},
Write: WriteNone,
})
if err != nil {
t.Fatal(err)
}
if !none.Uses.Has(CapFSWriteDeny) {
t.Fatalf("WriteNone on AppContainer must claim fs.write.deny: %v", none.Uses.List())
}
if !none.ac.DeriveOnlyLowbox {
t.Fatalf("WriteNone must use derive-only profile storage: %+v", none.ac)
}
if len(none.ac.WriteGrants) != 0 {
t.Fatalf("WriteNone must not grant writable host paths: %+v", none.ac)
}
if !profileHas(none.Profile, "profile storage: derive-only") {
t.Fatalf("WriteNone profile must show derive-only storage:\n%s", none.Profile)
}
if !caveatContains(none.Caveats, "ALL APPLICATION PACKAGES") {
t.Fatalf("WriteNone must caveat ambient ALL APPLICATION PACKAGES writes: %v", none.Caveats)
}
ephemeral, err := compileAppContainer(Spec{
Args: []string{exe},
Net: NetEnable,
Readable: []string{readable},
Write: WriteEphemeral,
})
if err != nil {
t.Fatal(err)
}
if !ephemeral.Uses.Has(CapFSWriteEphemeral) || ephemeral.Uses.Has(CapFSWriteDeny) {
t.Fatalf("ephemeral capabilities wrong: %v", ephemeral.Uses.List())
}
if !ephemeral.ac.DeriveOnlyLowbox {
t.Fatalf("WriteEphemeral must avoid per-profile storage: %+v", ephemeral.ac)
}
if ephemeral.ac.WorkDir != isoboxEphemeralRootPlaceholder {
t.Fatalf("ephemeral workdir=%q, want placeholder", ephemeral.ac.WorkDir)
}
if !hasString(ephemeral.ac.ReadGrants, isoboxEphemeralRootPlaceholder) || !hasString(ephemeral.ac.WriteGrants, isoboxEphemeralRootPlaceholder) {
t.Fatalf("ephemeral grants must include clone placeholder: %+v", ephemeral.ac)
}
if ephemeral.fs == nil || ephemeral.fs.Kind != fsVirtualizationWindowsWorkspaceCopy {
t.Fatalf("ephemeral should request Windows workspace copy, got %#v", ephemeral.fs)
}
if !profileHas(ephemeral.Profile, isoboxEphemeralRootPlaceholder) {
t.Fatalf("ephemeral profile should contain clone placeholder:\n%s", ephemeral.Profile)
}
if !caveatContains(ephemeral.Caveats, "workspace-scoped") || !caveatContains(ephemeral.Caveats, "full byte copy") {
t.Fatalf("missing ephemeral workspace-copy caveats: %v", ephemeral.Caveats)
}
overlay, err := compileAppContainer(Spec{
Args: []string{exe},
Net: NetEnable,
Readable: []string{readable},
ReadDeny: []string{filepath.Join(readable, "secret")},
Write: WriteOverlay,
Writable: []string{writable},
AllowTemp: true,
})
if err != nil {
t.Fatal(err)
}
if !hasString(overlay.ac.ReadDeny, canonPath(filepath.Join(readable, "secret"))) {
t.Fatalf("missing overlay read-deny path in profile: %+v", overlay.ac)
}
if !profileHas(overlay.Profile, "read deny:") {
t.Fatalf("profile must render read-deny paths:\n%s", overlay.Profile)
}
if !overlay.Uses.Has(CapFSWriteScope) || overlay.Uses.Has(CapFSWriteEphemeral) {
t.Fatalf("overlay degrade capabilities wrong: %v", overlay.Uses.List())
}
if !hasString(overlay.ac.WriteGrants, canonPath(writable)) {
t.Fatalf("missing overlay writable grant %s in %v", canonPath(writable), overlay.ac.WriteGrants)
}
if !overlay.ac.DeriveOnlyLowbox {
t.Fatalf("WriteOverlay must avoid per-profile storage: %+v", overlay.ac)
}
if !caveatContains(overlay.Caveats, "no ephemeral/shadow overlay") {
t.Fatalf("missing overlay degrade caveat: %v", overlay.Caveats)
}
if !caveatContains(overlay.Caveats, "temporary DENY ACEs") {
t.Fatalf("missing read-deny ACL caveat: %v", overlay.Caveats)
}
if !caveatContains(overlay.Caveats, "ambient write access") {
t.Fatalf("WriteOverlay must caveat ambient writable ACLs: %v", overlay.Caveats)
}
}
func TestAppContainerEphemeralPlaceholderReplacement(t *testing.T) {
exe := testExecutable(t)
p, err := compileAppContainer(Spec{Args: []string{exe}, Write: WriteEphemeral})
if err != nil {
t.Fatal(err)
}
const clone = `C:\isobox\clone`
replacePlanPlaceholder(p, isoboxEphemeralRootPlaceholder, clone)
if p.ac.WorkDir != clone {
t.Fatalf("workdir placeholder not replaced: %+v", p.ac)
}
if !hasString(p.ac.ReadGrants, clone) || !hasString(p.ac.WriteGrants, clone) {
t.Fatalf("grant placeholders not replaced: %+v", p.ac)
}
if profileHas(p.Profile, isoboxEphemeralRootPlaceholder) || !profileHas(p.Profile, clone) {
t.Fatalf("profile placeholder replacement failed:\n%s", p.Profile)
}
}
func TestAppContainerNoExecAndProfileShape(t *testing.T) {
exe := testExecutable(t)
readable := t.TempDir()
p, err := compileAppContainer(Spec{Args: []string{exe, "arg"}, Net: NetOutbound, Readable: []string{readable}, NoExec: true})
if err != nil {
t.Fatal(err)
}
if p.Backend != BackendAppContainer {
t.Fatalf("backend=%s", p.Backend)
}
if len(p.Argv) != 2 || p.Argv[0] != p.ac.Exe || p.Argv[1] != "arg" {
t.Fatalf("argv not resolved inner command: argv=%v ac=%+v", p.Argv, p.ac)
}
if !p.ac.ChildRestricted || !p.Uses.Has(CapProcNoExec) {
t.Fatalf("no-exec not reflected: uses=%v ac=%+v", p.Uses.List(), p.ac)
}
for _, frag := range []string{"appcontainer isobox-", "WinCapabilityInternetClientSid", "child process policy: restricted", p.ac.Exe} {
if !profileHas(p.Profile, frag) {
t.Fatalf("profile missing %q:\n%s", frag, p.Profile)
}
}
// R8: AppContainer's no-exec is no-new-process, not no-new-exec, so isobox
// must caveat the stronger Win32 contract.
if !caveatContains(p.Caveats, "PROCESS_CREATION_CHILD_PROCESS_RESTRICTED") {
t.Fatalf("no-exec must caveat PROCESS_CREATION_CHILD_PROCESS_RESTRICTED: %v", p.Caveats)
}
if !caveatContains(p.Caveats, "ALL child-process creation") {
t.Fatalf("no-exec must caveat that fork-like creation is also blocked: %v", p.Caveats)
}
if !caveatContains(p.Caveats, "broker") {
t.Fatalf("no-exec must caveat the out-of-process broker escape: %v", p.Caveats)
}
noNoExec, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, Readable: []string{readable}})
if err != nil {
t.Fatal(err)
}
for _, c := range noNoExec.Caveats {
if strings.Contains(c, "PROCESS_CREATION_CHILD_PROCESS_RESTRICTED") {
t.Fatalf("no-exec caveat must not appear when NoExec is false: %v", noNoExec.Caveats)
}
}
}
// R6: per-run AppContainer profile names must differ across calls so a crashed
// run cannot leak ACL grants into a future run that shares the same Spec.
func TestAppContainerProfileNameUniquePerRun(t *testing.T) {
exe := testExecutable(t)
readable := t.TempDir()
spec := Spec{
Args: []string{exe, "arg"},
Net: NetEnable,
Readable: []string{readable},
Write: WriteNone,
}
const trials = 8
seen := make(map[string]struct{}, trials)
for i := 0; i < trials; i++ {
p, err := compileAppContainer(spec)
if err != nil {
t.Fatal(err)
}
name := p.ac.ProfileName
if !strings.HasPrefix(name, "isobox-") {
t.Fatalf("profile name %q missing isobox- prefix", name)
}
// 16 hex chars after "isobox-" → 21-char total.
if len(name) != len("isobox-")+16 {
t.Fatalf("profile name %q must be isobox- + 16 hex chars, got len=%d", name, len(name))
}
if _, dup := seen[name]; dup {
t.Fatalf("profile name %q repeated across compileAppContainer calls; per-run uniqueness violated", name)
}
seen[name] = struct{}{}
}
}
func TestAppContainerResourceLimits(t *testing.T) {
exe := testExecutable(t)
p, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, CPUs: 1.5, MemoryBytes: 512 << 20, PIDs: 64})
if err != nil {
t.Fatal(err)
}
if p.ac.CPUs != 1.5 || p.ac.MemoryBytes != 512<<20 || p.ac.PIDs != 64 {
t.Fatalf("profile resource fields wrong: cpus=%v mem=%d pids=%d", p.ac.CPUs, p.ac.MemoryBytes, p.ac.PIDs)
}
if !p.Uses.Has(CapResCPU) || !p.Uses.Has(CapResMemory) || !p.Uses.Has(CapResPIDs) {
t.Fatalf("plan uses missing resource caps: %v", p.Uses.List())
}
for _, frag := range []string{"cpu limit: 1.5 cores", "memory limit: 536870912 bytes", "process limit: 64"} {
if !profileHas(p.Profile, frag) {
t.Fatalf("profile missing %q:\n%s", frag, p.Profile)
}
}
}
func TestAppContainerOmitsResourceLinesWithoutLimits(t *testing.T) {
exe := testExecutable(t)
p, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable})
if err != nil {
t.Fatal(err)
}
if p.Uses.Has(CapResCPU) || p.Uses.Has(CapResMemory) || p.Uses.Has(CapResPIDs) {
t.Fatalf("no limits must not claim resource caps: %v", p.Uses.List())
}
if strings.Contains(p.Profile, "cpu limit:") || strings.Contains(p.Profile, "memory limit:") || strings.Contains(p.Profile, "process limit:") {
t.Fatalf("no limits must omit resource lines:\n%s", p.Profile)
}
}
// FIX 1: scoped reads must caveat that additive ACL grants cannot revoke the
// ambient ALL APPLICATION PACKAGES readability, without claiming broad reads.
func TestAppContainerScopedReadAmbientCaveat(t *testing.T) {
exe := testExecutable(t)
readable := t.TempDir()
p, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, Readable: []string{readable}})
if err != nil {
t.Fatal(err)
}
if !caveatContains(p.Caveats, "ALL APPLICATION PACKAGES") {
t.Fatalf("scoped reads must caveat ambient ALL APPLICATION PACKAGES readability: %v", p.Caveats)
}
if caveatContains(p.Caveats, "broad host reads") {
t.Fatalf("scoped reads must not warn about broad host reads: %v", p.Caveats)
}
}
// FIX 2: the memory caveat must clarify that footprint can exceed the commit cap.
func TestAppContainerMemoryCommitCaveat(t *testing.T) {
exe := testExecutable(t)
p, err := compileAppContainer(Spec{Args: []string{exe}, Net: NetEnable, MemoryBytes: 256 << 20})
if err != nil {
t.Fatal(err)
}
if !caveatContains(p.Caveats, "working-set") {
t.Fatalf("memory caveat must note that physical footprint can exceed the commit cap: %v", p.Caveats)
}
}
// cpuRateHundredths maps a logical-core count onto the Windows job-object
// CpuRate hard cap (hundredths of a percent of all host processors).
func TestCPURateHundredths(t *testing.T) {
n := float64(runtime.NumCPU())
if got := cpuRateHundredths(n); got != cpuRateMaxHundredths {
t.Fatalf("all host cores: got %d, want %d", got, cpuRateMaxHundredths)
}
if got := cpuRateHundredths(n * 4); got != cpuRateMaxHundredths {
t.Fatalf("oversubscribed request must clamp to %d, got %d", cpuRateMaxHundredths, got)
}
if got := cpuRateHundredths(n / 2); got != cpuRateMaxHundredths/2 {
t.Fatalf("half the host: got %d, want %d", got, cpuRateMaxHundredths/2)
}
if got := cpuRateHundredths(1e-9); got < 1 {
t.Fatalf("tiny request must floor at 1, got %d", got)
}
}