Skip to content

Commit dd79885

Browse files
committed
extend the tests to include tests for python
1 parent c2b3915 commit dd79885

15 files changed

Lines changed: 360 additions & 0 deletions

.vscode/c_cpp_properties.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"configurations": [
3+
{
4+
"name": "Linux",
5+
"includePath": [
6+
"${workspaceFolder}/**"
7+
],
8+
"defines": [],
9+
"compilerPath": "/usr/bin/gcc",
10+
"cStandard": "c17",
11+
"cppStandard": "gnu++17",
12+
"intelliSenseMode": "linux-gcc-x64"
13+
}
14+
],
15+
"version": 4
16+
}

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"C_Cpp.errorSquiggles": "disabled"
3+
}

integration_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,38 @@ func TestContainerizationAPISecurityIntegration(t *testing.T) {
104104
}
105105
}
106106

107+
func TestContainerizationAPISecurityIntegrationPython3(t *testing.T) {
108+
if config.Config.Globals.ENABLE_QUEUE {
109+
t.Fatal("ENABLE_QUEUE must be false for integration tests")
110+
}
111+
112+
h := integrationHarness{baseURL: testServer.URL}
113+
h.apiPort = mustExtractPort(t, h.baseURL)
114+
if !python3SupportAvailable(t, h.baseURL) {
115+
t.Skip("python3 execution is not supported by the API yet")
116+
}
117+
118+
cases := []struct {
119+
name string
120+
run func(*testing.T, integrationHarness)
121+
}{
122+
{name: "file privacy across request IDs", run: testFilesystemIsolationPython3},
123+
{name: "disk spammer is terminated and data is reclaimed", run: testDiskCleanupPython3},
124+
{name: "fork bomb does not poison subsequent requests", run: testForkBombContainmentPython3},
125+
{name: "network namespace blocks localhost bridge", run: testNetworkIsolationPython3},
126+
{name: "memory hard limit triggers oom kill", run: testMemoryHardLimitPython3},
127+
{name: "io flood is bounded and returns before timeout", run: testIOFloodResiliencePython3},
128+
{name: "signal trap cannot survive forced timeout", run: testSignalTrapTimeoutPython3},
129+
{name: "orphan grandchild is reaped after request exits", run: testOrphanReapingPython3},
130+
{name: "inode bomb does not poison host temp filesystem", run: testInodeExhaustionPython3},
131+
{name: "privileged reboot syscall is denied", run: testPrivilegedSyscallDeniedPython3},
132+
}
133+
134+
for _, tc := range cases {
135+
t.Run(tc.name, func(t *testing.T) { tc.run(t, h) })
136+
}
137+
}
138+
107139
func testFilesystemIsolation(t *testing.T, h integrationHarness) {
108140
writeCode := mustLoadSampleCode(t, "file_privacy_write_read.c", nil)
109141
writeResp := callSimpleExecute(t, h.baseURL, buildCRequest(writeCode, 4, 32768))
@@ -266,6 +298,155 @@ func testPrivilegedSyscallDenied(t *testing.T, h integrationHarness) {
266298
}
267299
}
268300

301+
func testFilesystemIsolationPython3(t *testing.T, h integrationHarness) {
302+
writeCode := mustLoadSampleCode(t, "file_privacy_write_read.py", nil)
303+
writeResp := callSimpleExecute(t, h.baseURL, buildPython3Request(writeCode, 4, 32768))
304+
if !strings.Contains(writeResp.Output, "SecretData123") {
305+
t.Fatalf("step A did not return secret in stdout; stdout=%q stderr=%q", writeResp.Output, writeResp.Error)
306+
}
307+
308+
readCode := mustLoadSampleCode(t, "file_privacy_read_only.py", nil)
309+
readResp := callSimpleExecute(t, h.baseURL, buildPython3Request(readCode, 4, 32768))
310+
if !strings.Contains(strings.ToLower(readResp.Error), "no such file or directory") {
311+
t.Fatalf("step B unexpectedly accessed file from another sandbox; stdout=%q stderr=%q", readResp.Output, readResp.Error)
312+
}
313+
}
314+
315+
func testDiskCleanupPython3(t *testing.T, h integrationHarness) {
316+
beforeFree := mustFreeBytes(t, os.TempDir())
317+
beforeCount := countSandboxTempDirs(t)
318+
319+
code := mustLoadSampleCode(t, "disk_spammer.py", nil)
320+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 2, 32768))
321+
stderrLower := strings.ToLower(resp.Error)
322+
if !containsAny(stderrLower, []string{"execution timed out", "memory limit exceeded"}) {
323+
t.Fatalf("disk spammer was not terminated as expected; stdout=%q stderr=%q", resp.Output, resp.Error)
324+
}
325+
326+
time.Sleep(400 * time.Millisecond)
327+
assertDiskReclaimed(t, beforeFree, mustFreeBytes(t, os.TempDir()))
328+
assertNoSandboxLeak(t, beforeCount, countSandboxTempDirs(t))
329+
}
330+
331+
func testForkBombContainmentPython3(t *testing.T, h integrationHarness) {
332+
bombCode := mustLoadSampleCode(t, "fork_bomb.py", nil)
333+
_ = callSimpleExecute(t, h.baseURL, buildPython3Request(bombCode, 2, 32768))
334+
335+
helloCode := mustLoadSampleCode(t, "hello_world.py", nil)
336+
helloResp := callSimpleExecute(t, h.baseURL, buildPython3Request(helloCode, 4, 32768))
337+
if !strings.Contains(helloResp.Output, "Hello World") {
338+
t.Fatalf("follow-up request failed after fork bomb; stdout=%q stderr=%q", helloResp.Output, helloResp.Error)
339+
}
340+
if strings.Contains(strings.ToLower(helloResp.Error), "resource temporarily unavailable") {
341+
t.Fatalf("follow-up request indicates host PID exhaustion; stderr=%q", helloResp.Error)
342+
}
343+
}
344+
345+
func testNetworkIsolationPython3(t *testing.T, h integrationHarness) {
346+
replacements := map[string]string{"__API_PORT__": strconv.Itoa(h.apiPort)}
347+
code := mustLoadSampleCode(t, "network_localhost_bridge.py", replacements)
348+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 3, 32768))
349+
350+
combined := strings.ToLower(resp.Output + "\n" + resp.Error)
351+
if strings.Contains(combined, "connected") {
352+
t.Fatalf("localhost bridge unexpectedly succeeded; stdout=%q stderr=%q", resp.Output, resp.Error)
353+
}
354+
if !containsAny(combined, []string{"connection refused", "network is unreachable", "no route to host"}) {
355+
t.Fatalf("network isolation did not produce expected connect error; stdout=%q stderr=%q", resp.Output, resp.Error)
356+
}
357+
}
358+
359+
func testMemoryHardLimitPython3(t *testing.T, h integrationHarness) {
360+
code := mustLoadSampleCode(t, "memory_bomb.py", nil)
361+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 3, 16384))
362+
if !containsAny(strings.ToLower(resp.Error), []string{"memory limit exceeded", "killed", "execution timed out"}) {
363+
t.Fatalf("memory bomb did not terminate as expected; stdout=%q stderr=%q", resp.Output, resp.Error)
364+
}
365+
366+
execDur, err := time.ParseDuration(resp.CPUTime)
367+
if err != nil {
368+
t.Fatalf("failed to parse cpu_time %q: %v", resp.CPUTime, err)
369+
}
370+
if execDur > 500*time.Millisecond {
371+
t.Fatalf("memory enforcement was too slow: cpu_time=%s stderr=%q", resp.CPUTime, resp.Error)
372+
}
373+
}
374+
375+
func testIOFloodResiliencePython3(t *testing.T, h integrationHarness) {
376+
code := mustLoadSampleCode(t, "io_spam.py", nil)
377+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 1, 32768))
378+
379+
stderrLower := strings.ToLower(resp.Error)
380+
if !containsAny(stderrLower, []string{"execution timed out", "killed", "memory limit exceeded"}) {
381+
t.Fatalf("io flood did not terminate with expected error; stdout=%q stderr=%q", resp.Output, resp.Error)
382+
}
383+
384+
if strings.TrimSpace(resp.Error) == "" {
385+
t.Fatalf("io flood returned empty stderr; expected bounded but non-empty error output")
386+
}
387+
388+
const maxErrorBytes = (1 << 20) + 4096
389+
if len(resp.Error) > maxErrorBytes {
390+
t.Fatalf("stderr exceeded resilience cap: got=%d bytes cap=%d", len(resp.Error), maxErrorBytes)
391+
}
392+
393+
assertDurationNotExcessive(t, resp.CPUTime, 3*time.Second, "io flood request")
394+
}
395+
396+
func testSignalTrapTimeoutPython3(t *testing.T, h integrationHarness) {
397+
code := mustLoadSampleCode(t, "signal_trap.py", nil)
398+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 1, 32768))
399+
400+
if !strings.Contains(strings.ToLower(resp.Error), "execution timed out") {
401+
t.Fatalf("signal trap did not timeout as expected; stdout=%q stderr=%q", resp.Output, resp.Error)
402+
}
403+
404+
assertDurationWindow(t, resp.CPUTime, 900*time.Millisecond, 2500*time.Millisecond, "signal trap timeout")
405+
}
406+
407+
func testOrphanReapingPython3(t *testing.T, h integrationHarness) {
408+
code := mustLoadSampleCode(t, "orphan_maker.py", nil)
409+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 2, 32768))
410+
assertDurationNotExcessive(t, resp.CPUTime, 3*time.Second, "orphan maker request")
411+
412+
time.Sleep(400 * time.Millisecond)
413+
if cnt := countProcessesByComm(t, "orphanpygc"); cnt > 0 {
414+
t.Fatalf("orphan grandchild leaked to host after request completion: count=%d stderr=%q", cnt, resp.Error)
415+
}
416+
}
417+
418+
func testInodeExhaustionPython3(t *testing.T, h integrationHarness) {
419+
beforeFree := mustFreeBytes(t, os.TempDir())
420+
beforeCount := countSandboxTempDirs(t)
421+
422+
code := mustLoadSampleCode(t, "inode_bomb.py", nil)
423+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 4, 32768))
424+
425+
combinedLower := strings.ToLower(resp.Output + "\n" + resp.Error)
426+
if !containsAny(combinedLower, []string{"inode bomb completed", "no space left", "disk quota exceeded"}) {
427+
t.Fatalf("inode exhaustion test produced unexpected result; stdout=%q stderr=%q", resp.Output, resp.Error)
428+
}
429+
430+
time.Sleep(400 * time.Millisecond)
431+
assertDiskReclaimed(t, beforeFree, mustFreeBytes(t, os.TempDir()))
432+
assertNoSandboxLeak(t, beforeCount, countSandboxTempDirs(t))
433+
mustCreateAndDeleteTempFile(t)
434+
}
435+
436+
func testPrivilegedSyscallDeniedPython3(t *testing.T, h integrationHarness) {
437+
code := mustLoadSampleCode(t, "try_reboot.py", nil)
438+
resp := callSimpleExecute(t, h.baseURL, buildPython3Request(code, 3, 32768))
439+
assertDurationNotExcessive(t, resp.CPUTime, 3*time.Second, "privileged reboot syscall")
440+
441+
combinedLower := strings.ToLower(resp.Output + "\n" + resp.Error)
442+
if strings.Contains(combinedLower, "reboot succeeded unexpectedly") {
443+
t.Fatalf("privileged reboot syscall unexpectedly succeeded; stdout=%q stderr=%q", resp.Output, resp.Error)
444+
}
445+
if !containsAny(combinedLower, []string{"operation not permitted", "permission denied", "bad system call", "killed", "hangup"}) {
446+
t.Fatalf("expected privileged syscall denial signal was not observed; stdout=%q stderr=%q", resp.Output, resp.Error)
447+
}
448+
}
449+
269450
func mustLoadSampleCode(t *testing.T, fileName string, replacements map[string]string) string {
270451
t.Helper()
271452
path := filepath.Join(sampleCodeDir, fileName)
@@ -285,6 +466,43 @@ func buildCRequest(code string, timeoutSec, maxMemoryKB uint) simpleExecuteReque
285466
return simpleExecuteRequest{Language: "c", Code: code, Timeout: timeoutSec, MaxMemory: maxMemoryKB}
286467
}
287468

469+
func buildPython3Request(code string, timeoutSec, maxMemoryKB uint) simpleExecuteRequest {
470+
return simpleExecuteRequest{Language: "python3", Code: code, Timeout: timeoutSec, MaxMemory: maxMemoryKB}
471+
}
472+
473+
func python3SupportAvailable(t *testing.T, baseURL string) bool {
474+
t.Helper()
475+
req := buildPython3Request("print('py-ok')", 1, 32768)
476+
body, err := json.Marshal(req)
477+
if err != nil {
478+
return false
479+
}
480+
481+
httpReq, err := http.NewRequest(http.MethodPost, baseURL+"/simple-execute", bytes.NewReader(body))
482+
if err != nil {
483+
return false
484+
}
485+
httpReq.Header.Set("Content-Type", "application/json")
486+
487+
httpResp, err := httpClient.Do(httpReq)
488+
if err != nil {
489+
return false
490+
}
491+
defer httpResp.Body.Close()
492+
493+
rawBody, err := io.ReadAll(httpResp.Body)
494+
if err != nil || httpResp.StatusCode != http.StatusOK {
495+
return false
496+
}
497+
498+
var parsed simpleExecuteResponse
499+
if err := json.Unmarshal(rawBody, &parsed); err != nil {
500+
return false
501+
}
502+
503+
return strings.Contains(parsed.Output, "py-ok")
504+
}
505+
288506
func callSimpleExecute(t *testing.T, baseURL string, req simpleExecuteRequest) simpleExecuteResponse {
289507
t.Helper()
290508
body, err := json.Marshal(req)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
buf = b'A' * (1024 * 1024)
2+
3+
with open('/disk_spam.bin', 'wb', buffering=0) as f:
4+
while True:
5+
f.write(buf)
6+
f.flush()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
3+
try:
4+
with open('/root/test.txt', 'r', encoding='utf-8') as f:
5+
print(f.read(), end='')
6+
except FileNotFoundError as exc:
7+
print(exc, file=sys.stderr)
8+
raise SystemExit(1)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import os
2+
3+
os.makedirs('/root', exist_ok=True)
4+
with open('/root/test.txt', 'w', encoding='utf-8') as f:
5+
f.write('SecretData123')
6+
7+
with open('/root/test.txt', 'r', encoding='utf-8') as f:
8+
print(f.read(), end='')

sample_code_for_tests/fork_bomb.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import os
2+
import signal
3+
import sys
4+
5+
while True:
6+
try:
7+
pid = os.fork()
8+
except OSError as exc:
9+
print(exc, file=sys.stderr)
10+
raise SystemExit(0)
11+
12+
if pid == 0:
13+
while True:
14+
signal.pause()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
print('Hello World')
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import sys
2+
3+
for i in range(10000):
4+
with open(f'file_{i}.txt', 'w', encoding='utf-8'):
5+
pass
6+
7+
print('inode bomb completed', file=sys.stderr)

sample_code_for_tests/io_spam.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
import time
3+
4+
line = 'IO_FLOOD:' + ('A' * 4086) + '\n'
5+
while True:
6+
sys.stderr.write(line)
7+
sys.stderr.flush()
8+
time.sleep(0.001)

0 commit comments

Comments
 (0)