From dc38576e03f6a5cf8a7e067c5dea8b8cdef8e158 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 19 May 2026 22:49:13 +0200 Subject: [PATCH 01/73] empty From dbef9824928773168e4e76e79b0561e49de7e28e Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 19 May 2026 23:39:24 +0200 Subject: [PATCH 02/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] signal-handling: test pipeline panic recovery --- interp/signal_handling_vuln_hunt_test.go | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/interp/signal_handling_vuln_hunt_test.go b/interp/signal_handling_vuln_hunt_test.go index 4110dec6..b5511bf3 100644 --- a/interp/signal_handling_vuln_hunt_test.go +++ b/interp/signal_handling_vuln_hunt_test.go @@ -23,6 +23,7 @@ package interp import ( "bytes" "context" + "strings" "testing" "time" @@ -104,3 +105,34 @@ func TestVulnHuntSubsystemSignalHandling_ParentCtxCancelStopsPipeline(t *testing assert.Less(t, elapsed, 2*time.Second, "pipeline did not stop promptly after parent-ctx cancel: %s", elapsed) } + +// TestVulnHuntSubsystemSignalHandling_PipelineLeftPanicRecoveryUsesCappedStderrAndUnblocks +// asserts that a panic in the left pipeline goroutine still closes the pipe, +// unblocks the parent, and writes the panic diagnostic through the Run-level +// stderr cap rather than a raw caller writer. +func TestVulnHuntSubsystemSignalHandling_PipelineLeftPanicRecoveryUsesCappedStderrAndUnblocks(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New( + allowAllCommandsOpt(), + StdIO(nil, &stdout, &stderr), + MaxExecutionTime(2*time.Second), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + r.Reset() + r.execHandler = func(context.Context, []string) error { + panic(strings.Repeat("B", maxStderrBytes+1024)) + } + + start := time.Now() + err = r.Run(context.Background(), parseScript(t, "panic_left | cat")) + elapsed := time.Since(start) + + require.EqualError(t, err, "internal error") + assert.Empty(t, stdout.String()) + assert.LessOrEqual(t, stderr.Len(), maxStderrBytes, + "pipeline-left panic recovery must write through the stderr cap") + assert.Less(t, elapsed, time.Second, + "pipeline-left panic recovery did not unblock the parent promptly: %s", elapsed) +} From d8dad6b09d548a64476be13f12e33442863ac144 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 19 May 2026 23:46:22 +0200 Subject: [PATCH 03/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] heredoc: add scenario coverage --- .../shell/heredoc/blocked_output_redirect.yaml | 14 ++++++++++++++ .../shell/heredoc/pipe_reader_closes_early.yaml | 13 +++++++++++++ .../quoted_unsupported_param_literal.yaml | 12 ++++++++++++ .../readonly_in_unquoted_cmdsubst_blocked.yaml | 14 ++++++++++++++ .../heredoc/stdin_restored_after_statement.yaml | 16 ++++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 tests/scenarios/shell/heredoc/blocked_output_redirect.yaml create mode 100644 tests/scenarios/shell/heredoc/pipe_reader_closes_early.yaml create mode 100644 tests/scenarios/shell/heredoc/quoted_unsupported_param_literal.yaml create mode 100644 tests/scenarios/shell/heredoc/readonly_in_unquoted_cmdsubst_blocked.yaml create mode 100644 tests/scenarios/shell/heredoc/stdin_restored_after_statement.yaml diff --git a/tests/scenarios/shell/heredoc/blocked_output_redirect.yaml b/tests/scenarios/shell/heredoc/blocked_output_redirect.yaml new file mode 100644 index 00000000..4b31936d --- /dev/null +++ b/tests/scenarios/shell/heredoc/blocked_output_redirect.yaml @@ -0,0 +1,14 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / heredoc +# skip: rshell intentionally blocks output redirection to files +skip_assert_against_bash: true +description: Heredoc does not make blocked output redirection to a file writable. +input: + script: |+ + cat < /tmp/evil + payload + EOF +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/heredoc/pipe_reader_closes_early.yaml b/tests/scenarios/shell/heredoc/pipe_reader_closes_early.yaml new file mode 100644 index 00000000..2c673943 --- /dev/null +++ b/tests/scenarios/shell/heredoc/pipe_reader_closes_early.yaml @@ -0,0 +1,13 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / heredoc +description: Heredoc writer stops cleanly when a pipeline reader exits early. +input: + script: |+ + cat < Date: Tue, 19 May 2026 23:53:28 +0200 Subject: [PATCH 04/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] grep: test numeric overflow rejection --- builtins/grep/builtin_grep_pentest_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/builtins/grep/builtin_grep_pentest_test.go b/builtins/grep/builtin_grep_pentest_test.go index 65a9367e..1ac56d23 100644 --- a/builtins/grep/builtin_grep_pentest_test.go +++ b/builtins/grep/builtin_grep_pentest_test.go @@ -286,6 +286,22 @@ func TestGrepPentestManyPatterns(t *testing.T) { }) } +func TestGrepPentestNumericFlagOverflowRejected(t *testing.T) { + dir := t.TempDir() + pentestWriteFile(t, dir, "file.txt", "target\n") + + for _, script := range []string{ + "grep -A 9223372036854775808 target file.txt", + "grep -B 9223372036854775808 target file.txt", + "grep -C 9223372036854775808 target file.txt", + "grep -m 9223372036854775808 target file.txt", + } { + _, stderr, code := grepRun(t, script, dir) + assert.Equal(t, 1, code, "script=%s", script) + assert.Contains(t, stderr, "grep:", "script=%s", script) + } +} + // --- Quiet mode with error --- func TestGrepPentestQuietWithMatch(t *testing.T) { From 1c83a99bdeff678e0f12f198ac89310c89281e00 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Tue, 19 May 2026 23:57:31 +0200 Subject: [PATCH 05/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] function: add composition scenarios --- .../shell/function/function_in_subshell_blocked.yaml | 12 ++++++++++++ .../function_with_devnull_redirect_blocked.yaml | 12 ++++++++++++ .../function_with_readonly_body_blocked.yaml | 12 ++++++++++++ .../function/function_with_redirect_blocked.yaml | 12 ++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 tests/scenarios/shell/function/function_in_subshell_blocked.yaml create mode 100644 tests/scenarios/shell/function/function_with_devnull_redirect_blocked.yaml create mode 100644 tests/scenarios/shell/function/function_with_readonly_body_blocked.yaml create mode 100644 tests/scenarios/shell/function/function_with_redirect_blocked.yaml diff --git a/tests/scenarios/shell/function/function_in_subshell_blocked.yaml b/tests/scenarios/shell/function/function_in_subshell_blocked.yaml new file mode 100644 index 00000000..33bc29b4 --- /dev/null +++ b/tests/scenarios/shell/function/function_in_subshell_blocked.yaml @@ -0,0 +1,12 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / function +# skip: function declarations are intentionally blocked in the restricted shell +skip_assert_against_bash: true +description: Function declaration inside a subshell is blocked at validation. +input: + script: |+ + ( inner() { echo leaked; }; inner ) +expect: + stdout: "" + stderr: |+ + function declarations are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/function/function_with_devnull_redirect_blocked.yaml b/tests/scenarios/shell/function/function_with_devnull_redirect_blocked.yaml new file mode 100644 index 00000000..c2b8c181 --- /dev/null +++ b/tests/scenarios/shell/function/function_with_devnull_redirect_blocked.yaml @@ -0,0 +1,12 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / function +# skip: function declarations are intentionally blocked in the restricted shell +skip_assert_against_bash: true +description: Function declaration with allowed devnull redirection is still rejected. +input: + script: |+ + f() { echo hidden; } > /dev/null +expect: + stdout: "" + stderr: |+ + function declarations are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/function/function_with_readonly_body_blocked.yaml b/tests/scenarios/shell/function/function_with_readonly_body_blocked.yaml new file mode 100644 index 00000000..f7563b62 --- /dev/null +++ b/tests/scenarios/shell/function/function_with_readonly_body_blocked.yaml @@ -0,0 +1,12 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / function +# skip: function declarations are intentionally blocked in the restricted shell +skip_assert_against_bash: true +description: Function body containing readonly is blocked before body execution. +input: + script: |+ + f() { readonly X=1; } +expect: + stdout: "" + stderr: |+ + function declarations are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/function/function_with_redirect_blocked.yaml b/tests/scenarios/shell/function/function_with_redirect_blocked.yaml new file mode 100644 index 00000000..231705e9 --- /dev/null +++ b/tests/scenarios/shell/function/function_with_redirect_blocked.yaml @@ -0,0 +1,12 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / function +# skip: function declarations are intentionally blocked in the restricted shell +skip_assert_against_bash: true +description: Function declaration with blocked output redirection is rejected as a function. +input: + script: |+ + f() { echo leaked; } > /tmp/evil +expect: + stdout: "" + stderr: |+ + function declarations are not supported + exit_code: 2 From 6d77f25115a68a30278f6682364d34a4b7244bf8 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 00:10:49 +0200 Subject: [PATCH 06/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] cut: add sandbox and special-file tests --- builtins/cut/cut_vuln_hunt_test.go | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/builtins/cut/cut_vuln_hunt_test.go b/builtins/cut/cut_vuln_hunt_test.go index 64714d3f..c0c4bbba 100644 --- a/builtins/cut/cut_vuln_hunt_test.go +++ b/builtins/cut/cut_vuln_hunt_test.go @@ -5,14 +5,18 @@ // Blocked-attack regression tests added by the vuln-hunt campaign // 2026-05-18-initial-audit (target: cut). +// Additional coverage: 2026-05-19-gpt-5.5-cyber-2. package cut_test import ( + "context" "os" "path/filepath" "runtime" + "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,6 +47,22 @@ func TestVulnHuntBuiltinFileAccessBypass_SymlinkOutsideSandbox(t *testing.T) { assert.NotEmpty(t, stderr) } +// H13: direct absolute file operands outside AllowedPaths must not be readable +// through cut's normal file-open path. +func TestVulnHuntBuiltinFileAccessBypass_AbsolutePathOutsideSandbox(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + secretPath := filepath.Join(secret, "secret.txt") + require.NoError(t, os.WriteFile(secretPath, []byte("S3CR3T\n"), 0644)) + + stdout, stderr, code := testutil.RunScript(t, + "cut -b1- "+strconv.Quote(secretPath), allowed, + interp.AllowedPaths([]string{allowed})) + assert.NotEqual(t, 0, code, "absolute outside path must fail") + assert.NotContains(t, stdout, "S3CR3T") + assert.Contains(t, stderr, "cut:") +} + // H3: --output-delimiter accepts arbitrary strings. Newlines / NUL bytes / // ANSI escapes inside the delimiter pass through to stdout verbatim, but this // stays inside the 1MB output cap and therefore inside the sandbox. @@ -111,3 +131,32 @@ func TestVulnHuntBuiltinIntegerOverflow_LargeUnboundedEnd(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "hello\n", stdout) } + +// H18: /dev/zero is an infinite stream without newlines. The scanner's +// MaxLineBytes cap must terminate cut promptly instead of waiting for EOF. +func TestVulnHuntBuiltinSpecialFiles_DevZeroTerminatesAtLineCap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, stderr, code := testutil.RunScriptCtx(ctx, t, + "cut -b1 /dev/zero", dir, interp.AllowedPaths([]string{"/dev"})) + require.NoError(t, ctx.Err(), "cut /dev/zero hung or timed out") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cut:") +} + +// H19: non-regular file operands must fail safely rather than being treated as +// normal input streams. +func TestVulnHuntBuiltinSpecialFiles_DirectoryAsFileErrors(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + + _, stderr, code := testutil.RunScript(t, "cut -b1 subdir", dir, + interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cut:") +} From 343b7d6691ae261d1a5a40b16aa5f97e6b217cb2 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 00:17:00 +0200 Subject: [PATCH 07/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] strings: add vuln hunt coverage --- .../strings_cmd/strings_vuln_hunt_test.go | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 builtins/strings_cmd/strings_vuln_hunt_test.go diff --git a/builtins/strings_cmd/strings_vuln_hunt_test.go b/builtins/strings_cmd/strings_vuln_hunt_test.go new file mode 100644 index 00000000..97a02974 --- /dev/null +++ b/builtins/strings_cmd/strings_vuln_hunt_test.go @@ -0,0 +1,139 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Blocked-attack regression tests added by the vuln-hunt campaign +// 2026-05-19-gpt-5.5-cyber-2 (target: strings_cmd). +package strings_cmd_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +func runVulnHuntStrings(t *testing.T, script, dir string, allowedPaths ...string) (string, string, int) { + t.Helper() + if len(allowedPaths) == 0 { + allowedPaths = []string{dir} + } + return testutil.RunScript(t, script, dir, interp.AllowedPaths(allowedPaths)) +} + +func writeVulnHuntStringsFile(t *testing.T, dir, name string, content []byte) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), content, 0644)) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_UnsupportedFlagsRejected(t *testing.T) { + dir := t.TempDir() + writeVulnHuntStringsFile(t, dir, "data.bin", []byte("visible\x00")) + + for _, flag := range []string{"--encoding=s", "--target=binary", "--include-all-whitespace"} { + _, stderr, code := runVulnHuntStrings(t, "strings "+flag+" data.bin", dir) + assert.Equal(t, 1, code, "flag: %s", flag) + assert.Contains(t, stderr, "strings:", "flag: %s", flag) + } +} + +func TestVulnHuntBuiltinFlagDrivenExploit_OutputSeparatorControlBytes(t *testing.T) { + dir := t.TempDir() + writeVulnHuntStringsFile(t, dir, "data.bin", []byte("alpha\x00bravo\x00")) + + stdout, stderr, code := runVulnHuntStrings(t, + `strings -s $'\nNEXT\n' data.bin`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Equal(t, "alpha\nNEXT\nbravo\nNEXT\n", stdout) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_PrintFileNameWithSpaces(t *testing.T) { + dir := t.TempDir() + name := "name with spaces.bin" + writeVulnHuntStringsFile(t, dir, name, []byte("alpha\x00")) + + stdout, stderr, code := runVulnHuntStrings(t, + "strings -f "+strconv.Quote(name), dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Equal(t, name+": alpha\n", stdout) +} + +func TestVulnHuntBuiltinFileAccessBypass_SymlinkOutsideSandbox(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks are restricted on Windows") + } + allowed := t.TempDir() + secret := t.TempDir() + secretPath := filepath.Join(secret, "secret.txt") + require.NoError(t, os.WriteFile(secretPath, []byte("S3CR3T\n"), 0644)) + require.NoError(t, os.Symlink(secretPath, filepath.Join(allowed, "link"))) + + stdout, stderr, code := runVulnHuntStrings(t, "strings link", allowed) + assert.Equal(t, 1, code) + assert.NotContains(t, stdout, "S3CR3T") + assert.Contains(t, stderr, "strings:") +} + +func TestVulnHuntBuiltinIntegerOverflow_MinLenBoundsRejected(t *testing.T) { + dir := t.TempDir() + writeVulnHuntStringsFile(t, dir, "data.bin", []byte("visible\x00")) + + for _, script := range []string{ + "strings -n -1 data.bin", + "strings -n 0 data.bin", + "strings -n 2147483648 data.bin", + "strings --bytes=999999999999999999999 data.bin", + } { + _, stderr, code := runVulnHuntStrings(t, script, dir) + assert.Equal(t, 1, code, "script: %s", script) + assert.Contains(t, stderr, "strings:", "script: %s", script) + } +} + +func TestVulnHuntBuiltinIntegerOverflow_MaxIntMinLenDoesNotAllocate(t *testing.T) { + dir := t.TempDir() + writeVulnHuntStringsFile(t, dir, "data.bin", []byte("visible\x00")) + + stdout, stderr, code := runVulnHuntStrings(t, "strings -n 2147483647 data.bin", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Equal(t, "", stdout) +} + +func TestVulnHuntBuiltinSpecialFiles_DevNullNoOutput(t *testing.T) { + if os.DevNull == "NUL" { + t.Skip("platform null device is a reserved filename on Windows") + } + dir := t.TempDir() + + stdout, stderr, code := runVulnHuntStrings(t, + "strings "+os.DevNull, dir, filepath.Dir(os.DevNull)) + assert.Equal(t, 0, code) + assert.Equal(t, "", stderr) + assert.Equal(t, "", stdout) +} + +func TestVulnHuntBuiltinSpecialFiles_DevZeroHonorsCancellation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, _, _ = testutil.RunScriptCtx(ctx, t, + "strings /dev/zero", dir, interp.AllowedPaths([]string{"/dev"})) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) +} From d022afdc4be208381d28a21c72ae270eb86ada8f Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 00:29:19 +0200 Subject: [PATCH 08/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] until: add composition scenarios --- interp/tests/while_clause_test.go | 22 +++++++++++++++++++ .../readonly_in_until_body_blocked.yaml | 14 ++++++++++++ .../redirect_loop_body_write_blocked.yaml | 13 +++++++++++ .../redirect_loop_to_devnull.yaml | 15 +++++++++++++ .../until_clause/subshell_until_isolated.yaml | 12 ++++++++++ 5 files changed, 76 insertions(+) create mode 100644 tests/scenarios/shell/until_clause/readonly_in_until_body_blocked.yaml create mode 100644 tests/scenarios/shell/until_clause/redirect_loop_body_write_blocked.yaml create mode 100644 tests/scenarios/shell/until_clause/redirect_loop_to_devnull.yaml create mode 100644 tests/scenarios/shell/until_clause/subshell_until_isolated.yaml diff --git a/interp/tests/while_clause_test.go b/interp/tests/while_clause_test.go index 981ce5c1..e3a83e76 100644 --- a/interp/tests/while_clause_test.go +++ b/interp/tests/while_clause_test.go @@ -501,6 +501,21 @@ func TestUntilFalseRespectsContextCancellation(t *testing.T) { assert.Less(t, time.Since(start), 5*time.Second, "until-loop did not terminate after ctx cancel") } +func TestUntilExitsOnPreCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + stdout, _, _ := whileRunCtx(ctx, t, `until false; do echo x; done`) + assert.Empty(t, stdout, "no body iterations should run when ctx is pre-cancelled") +} + +func TestUntilFalseOutputRespectsStdoutCap(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + stdout, _, _ := whileRunCtx(ctx, t, `until false; do echo x; done`) + const generousUpperBound = 1 << 25 + assert.Less(t, len(stdout), generousUpperBound, "stdout grew past the cap; runaway until loop?") +} + // vuln-hunt S2 (2026-05-18-initial-audit): an infinite while loop placed // inside a paren subshell must still honour the outer ctx cancel. The // subshell uses a forked runner with shared ctx, so the cancel must @@ -526,3 +541,10 @@ func TestWhileEmptyCondIsParserError(t *testing.T) { require.Error(t, err, "empty while-cond must be rejected by the parser") assert.Contains(t, err.Error(), "while", "parser error should mention 'while'") } + +func TestUntilEmptyCondIsParserError(t *testing.T) { + parser := syntax.NewParser() + _, err := parser.Parse(strings.NewReader(`until ; do :; done`), "") + require.Error(t, err, "empty until-cond must be rejected by the parser") + assert.Contains(t, err.Error(), "until", "parser error should mention 'until'") +} diff --git a/tests/scenarios/shell/until_clause/readonly_in_until_body_blocked.yaml b/tests/scenarios/shell/until_clause/readonly_in_until_body_blocked.yaml new file mode 100644 index 00000000..8efd018c --- /dev/null +++ b/tests/scenarios/shell/until_clause/readonly_in_until_body_blocked.yaml @@ -0,0 +1,14 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / until_clause +# skip: readonly is intentionally blocked in the restricted shell +skip_assert_against_bash: true +description: readonly nested in an until body is rejected before loop execution. +input: + script: |+ + until false; do + readonly X=1 + break + done +expect: + stdout: "" + stderr: "readonly is not supported\n" + exit_code: 2 diff --git a/tests/scenarios/shell/until_clause/redirect_loop_body_write_blocked.yaml b/tests/scenarios/shell/until_clause/redirect_loop_body_write_blocked.yaml new file mode 100644 index 00000000..b02fcdd1 --- /dev/null +++ b/tests/scenarios/shell/until_clause/redirect_loop_body_write_blocked.yaml @@ -0,0 +1,13 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / until_clause +# skip: redirect-to-file restriction is an rshell-specific sandbox feature. +skip_assert_against_bash: true +description: A write redirect attached to an until loop is rejected by the redirect allowlist. +input: + script: |+ + until false; do echo body; break; done > /tmp/until_loop_out.txt + echo unreachable +expect: + stdout: |+ + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/until_clause/redirect_loop_to_devnull.yaml b/tests/scenarios/shell/until_clause/redirect_loop_to_devnull.yaml new file mode 100644 index 00000000..4e0a060e --- /dev/null +++ b/tests/scenarios/shell/until_clause/redirect_loop_to_devnull.yaml @@ -0,0 +1,15 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / until_clause +description: until loop with stdout redirected to /dev/null suppresses only the loop body output. +input: + script: |+ + i= + until [ "$i" = aa ]; do + i="${i}a" + echo body + done > /dev/null + echo done +expect: + stdout: |+ + done + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/until_clause/subshell_until_isolated.yaml b/tests/scenarios/shell/until_clause/subshell_until_isolated.yaml new file mode 100644 index 00000000..0bcde784 --- /dev/null +++ b/tests/scenarios/shell/until_clause/subshell_until_isolated.yaml @@ -0,0 +1,12 @@ +# vuln-hunt 2026-05-19-gpt-5.5-cyber-2 / until_clause +description: assignments inside an until loop in a subshell do not leak to the parent shell. +input: + script: |+ + x=outer + ( until false; do x=inner; break; done ) + echo "$x" +expect: + stdout: |+ + outer + stderr: |+ + exit_code: 0 From ba84b09f9345736b1724a2085ad1d174dcb92bbc Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 00:39:47 +0200 Subject: [PATCH 09/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] pwd: add symlink cwd coverage --- builtins/pwd/pwd_vuln_hunt_test.go | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/builtins/pwd/pwd_vuln_hunt_test.go b/builtins/pwd/pwd_vuln_hunt_test.go index a9520f90..e9cb133c 100644 --- a/builtins/pwd/pwd_vuln_hunt_test.go +++ b/builtins/pwd/pwd_vuln_hunt_test.go @@ -3,8 +3,9 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2026-present Datadog, Inc. -// Blocked-attack regression tests added by the vuln-hunt campaign -// 2026-05-18-initial-audit (target: pwd). +// Blocked-attack regression tests added by vuln-hunt campaigns: +// - 2026-05-18-initial-audit (target: pwd) +// - 2026-05-19-gpt-5.5-cyber-2 (target: pwd) package pwd_test import ( @@ -69,3 +70,27 @@ func TestVulnHuntBuiltinFileAccessBypass_SymlinkTargetOutsideSandbox(t *testing. assert.True(t, strings.HasSuffix(strings.TrimSpace(stdout2), "real-cwd"), "pwd must return the logical cwd, got %q", stdout2) } + +func TestVulnHuntBuiltinFileAccessBypass_PhysicalSymlinkCwdDoesNotGrantRead(t *testing.T) { + if filepath.Separator == '\\' { + t.Skip("symlinks differ on Windows") + } + root := canonicalTempDir(t) + outside := canonicalTempDir(t) + secret := filepath.Join(outside, "secret.txt") + require.NoError(t, os.WriteFile(secret, []byte("outside-secret\n"), 0644)) + + link := filepath.Join(root, "cwd-link") + require.NoError(t, os.Symlink(outside, link)) + + stdout, stderr, code := testutil.RunScript(t, ` +physical=$(pwd -P) +printf 'PHYSICAL=%s\n' "$physical" +cat "$physical/secret.txt" +`, link, interp.AllowedPaths([]string{root})) + + assert.NotEqual(t, 0, code, "cat through pwd -P outside path must fail; stdout=%q stderr=%q", stdout, stderr) + assert.Contains(t, stdout, "PHYSICAL="+outside) + assert.NotContains(t, stdout, "outside-secret", "pwd -P must not turn an outside physical path into readable content") + assert.Contains(t, stderr, "cat:") +} From 3e72f2db5743d838f5cb04499aadfe2d198cbadc Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 09:53:15 +0200 Subject: [PATCH 10/73] [vuln-hunt 2026-05-19-gpt-5.5-cyber-2] brace group: add vuln hunt coverage --- interp/brace_group_vuln_hunt_test.go | 78 +++++++++++++++++++ interp/var_size_test.go | 28 +++++++ .../dynamic_devnull_redirect_blocked.yaml | 12 +++ .../brace_group/expansion_output_is_data.yaml | 10 +++ .../brace_group/fd_dup_restores_streams.yaml | 11 +++ ...direct_outside_blocked_state_isolated.yaml | 16 ++++ .../brace_group/pipeline_exit_isolated.yaml | 10 +++ .../redirect_to_devnull_restores_stdout.yaml | 10 +++ .../brace_group/redirect_to_file_blocked.yaml | 11 +++ 9 files changed, 186 insertions(+) create mode 100644 interp/brace_group_vuln_hunt_test.go create mode 100644 tests/scenarios/shell/brace_group/dynamic_devnull_redirect_blocked.yaml create mode 100644 tests/scenarios/shell/brace_group/expansion_output_is_data.yaml create mode 100644 tests/scenarios/shell/brace_group/fd_dup_restores_streams.yaml create mode 100644 tests/scenarios/shell/brace_group/input_redirect_outside_blocked_state_isolated.yaml create mode 100644 tests/scenarios/shell/brace_group/pipeline_exit_isolated.yaml create mode 100644 tests/scenarios/shell/brace_group/redirect_to_devnull_restores_stdout.yaml create mode 100644 tests/scenarios/shell/brace_group/redirect_to_file_blocked.yaml diff --git a/interp/brace_group_vuln_hunt_test.go b/interp/brace_group_vuln_hunt_test.go new file mode 100644 index 00000000..013c8b6f --- /dev/null +++ b/interp/brace_group_vuln_hunt_test.go @@ -0,0 +1,78 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-19-gpt-5.5-cyber-2 +// Target: brace_group (shell-feature) + +package interp + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntShellFeatureParserConfusion_MalformedBraceGroups(t *testing.T) { + tests := []string{ + `{ }`, + `{ echo unterminated`, + `{ echo missing-semicolon }`, + } + for _, src := range tests { + t.Run(src, func(t *testing.T) { + assert.Error(t, parseScriptWantErr(t, src)) + }) + } +} + +func TestVulnHuntShellFeatureReadonlyBypass_BraceGroupAssignmentBlocked(t *testing.T) { + stdout, stderr := runScriptWithReadonly(t, + "{ RO_VAR=hacked; echo inside=$RO_VAR; }\necho after=$RO_VAR\n") + + assert.Contains(t, stderr, "readonly variable", + "brace-group assignment to readonly must produce readonly error") + assert.NotContains(t, stdout, "hacked", + "brace group must not observe or leak a bypassed readonly value") + assert.Contains(t, stdout, "inside=original", + "the failed assignment must leave the readonly value visible inside the block") + assert.Contains(t, stdout, "after=original", + "the readonly value must remain intact after the block") +} + +func TestVulnHuntShellFeatureSignalContext_BraceGroupInfiniteLoopRespectsCancellation(t *testing.T) { + r := newTimeoutRunner(t, MaxExecutionTime(100*time.Millisecond)) + + start := time.Now() + err := r.Run(context.Background(), parseScript(t, `{ while true; do :; done; }`)) + elapsed := time.Since(start) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, elapsed, 2*time.Second, + "brace-group loop did not stop promptly after timeout: %s", elapsed) +} + +func TestVulnHuntShellFeatureSignalContext_BraceGroupPipelineStageClosesOnCancel(t *testing.T) { + var stdout, stderr bytes.Buffer + r := newTimeoutRunner(t, + StdIO(nil, &stdout, &stderr), + MaxExecutionTime(500*time.Millisecond), + ) + + start := time.Now() + err := r.Run(context.Background(), parseScript(t, `{ while true; do echo x; done; } | head -n 1`)) + elapsed := time.Since(start) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Equal(t, "x\n", stdout.String(), + "head should receive the first line before the pipeline is cancelled") + assert.Less(t, elapsed, 2*time.Second, + "brace-group pipeline did not stop promptly after timeout: %s", elapsed) +} diff --git a/interp/var_size_test.go b/interp/var_size_test.go index 6537cf6a..e33ce1dc 100644 --- a/interp/var_size_test.go +++ b/interp/var_size_test.go @@ -151,6 +151,34 @@ func TestBackgroundSubshellCapEnforced(t *testing.T) { "parent shell must continue after background subshell fails") } +func TestVulnHuntShellFeatureDeclaredVsImplemented_BraceGroupVariableCap(t *testing.T) { + large := strings.Repeat("x", interp.MaxVarBytes+1) + script := fmt.Sprintf("{ BIG=%s; echo \"[$BIG]\"; }\necho DONE\n", large) + + stdout, stderr, code := runScript(t, script, "") + + assert.Contains(t, stderr, "value too large") + assert.Contains(t, stdout, "[]\n", "oversized value must not be assigned inside the brace group") + assert.Contains(t, stdout, "DONE\n", "non-fatal per-variable cap should not corrupt the statement list") + assert.Equal(t, 0, code) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_PipelineBraceGroupVariableCap(t *testing.T) { + value900K := strings.Repeat("x", 900*1024) + value200K := strings.Repeat("y", 200*1024) + script := fmt.Sprintf("A=%s\necho seed | { B=%s; echo SHOULD_NOT_PRINT; }\necho DONE\n", + value900K, value200K) + + stdout, stderr, code := runScript(t, script, "") + + assert.NotContains(t, stdout, "SHOULD_NOT_PRINT", + "pipeline brace group must not execute after total storage cap is exceeded") + assert.Contains(t, stderr, "variable storage limit exceeded") + assert.Contains(t, stdout, "DONE\n", + "parent shell must continue after pipeline brace-group storage failure") + assert.Equal(t, 0, code) +} + // TestTotalVarStorageCapUpdateTracking verifies that updating an existing variable // correctly adjusts the total byte counter (i.e. growing a variable counts against // the cap, and shrinking it frees space). diff --git a/tests/scenarios/shell/brace_group/dynamic_devnull_redirect_blocked.yaml b/tests/scenarios/shell/brace_group/dynamic_devnull_redirect_blocked.yaml new file mode 100644 index 00000000..81c675da --- /dev/null +++ b/tests/scenarios/shell/brace_group/dynamic_devnull_redirect_blocked.yaml @@ -0,0 +1,12 @@ +# skip: redirect restrictions are an rshell-specific security feature +skip_assert_against_bash: true +description: Brace group redirection rejects dynamically expanded /dev/null targets. +input: + script: |+ + TARGET=/dev/null + { echo hidden; } > "$TARGET" +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/brace_group/expansion_output_is_data.yaml b/tests/scenarios/shell/brace_group/expansion_output_is_data.yaml new file mode 100644 index 00000000..e094ce18 --- /dev/null +++ b/tests/scenarios/shell/brace_group/expansion_output_is_data.yaml @@ -0,0 +1,10 @@ +description: Expansion output inside a brace group is data, not shell source. +input: + script: |+ + PAYLOAD='; echo injected' + { echo "$PAYLOAD"; } +expect: + stdout: |+ + ; echo injected + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/fd_dup_restores_streams.yaml b/tests/scenarios/shell/brace_group/fd_dup_restores_streams.yaml new file mode 100644 index 00000000..ddfda26f --- /dev/null +++ b/tests/scenarios/shell/brace_group/fd_dup_restores_streams.yaml @@ -0,0 +1,11 @@ +description: Brace group fd duplication is scoped and restores streams afterward. +input: + script: |+ + { echo err >&2; } 2>&1 + echo out +expect: + stdout: |+ + err + out + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/input_redirect_outside_blocked_state_isolated.yaml b/tests/scenarios/shell/brace_group/input_redirect_outside_blocked_state_isolated.yaml new file mode 100644 index 00000000..6994e43c --- /dev/null +++ b/tests/scenarios/shell/brace_group/input_redirect_outside_blocked_state_isolated.yaml @@ -0,0 +1,16 @@ +# skip: sandbox restrictions are an rshell-specific security feature +skip_assert_against_bash: true +description: Failed brace group input redirect does not poison the next statement. +setup: + files: + - path: ok.txt + content: "ok-payload" +input: + allowed_paths: ["$DIR"] + script: |+ + { cat; } /dev/null + echo visible +expect: + stdout: |+ + visible + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/redirect_to_file_blocked.yaml b/tests/scenarios/shell/brace_group/redirect_to_file_blocked.yaml new file mode 100644 index 00000000..8ccf05c5 --- /dev/null +++ b/tests/scenarios/shell/brace_group/redirect_to_file_blocked.yaml @@ -0,0 +1,11 @@ +# skip: redirect restrictions are an rshell-specific security feature +skip_assert_against_bash: true +description: Brace group output redirection to a real file is blocked. +input: + script: |+ + { echo owned; } >/tmp/brace-group-out +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 From 8a3f45556f64e56d5ad9fb75e001b9ab57d83a3d Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 11:01:49 +0200 Subject: [PATCH 11/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] negation: test blocked probes --- interp/negation_vuln_hunt_test.go | 134 ++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 interp/negation_vuln_hunt_test.go diff --git a/interp/negation_vuln_hunt_test.go b/interp/negation_vuln_hunt_test.go new file mode 100644 index 00000000..aaf31346 --- /dev/null +++ b/interp/negation_vuln_hunt_test.go @@ -0,0 +1,134 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: negation (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runNegationVulnHuntScript(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), parseScript(t, script)) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_NegationTokenFromExpansionNotReparsed(t *testing.T) { + stdout, stderr, code, err := runNegationVulnHuntScript(t, "bang='!'\n$bang false\necho status=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=127\n", stdout) + assert.Contains(t, stderr, "rshell: !: unknown command") +} + +func TestVulnHuntShellFeatureParserConfusion_EscapedBangIsNotNegation(t *testing.T) { + stdout, stderr, code, err := runNegationVulnHuntScript(t, "\\! false\necho escaped=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "escaped=127\n", stdout) + assert.Contains(t, stderr, "rshell: !: unknown command") +} + +func TestVulnHuntShellFeatureSubshellIsolation_NegatedExitStaysInSubshell(t *testing.T) { + stdout, stderr, code, err := runNegationVulnHuntScript(t, "x=parent\n! (x=child; exit 7)\necho status=$? x=$x\n! (true)\necho true_status=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "status=0 x=parent\ntrue_status=1\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_ExitAndTimeoutNotMasked(t *testing.T) { + stdout, stderr, code, err := runNegationVulnHuntScript(t, "! exit 7\necho unreachable\n", "") + + require.NoError(t, err) + assert.Equal(t, 7, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + + r := newTimeoutRunner(t, MaxExecutionTime(100*time.Millisecond)) + start := time.Now() + err = r.Run(context.Background(), parseScript(t, "! while true; do :; done")) + elapsed := time.Since(start) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, elapsed, 2*time.Second, "negated infinite loop did not stop promptly: %s", elapsed) +} + +func TestVulnHuntShellFeatureCompositionAttack_NegatedPipelineFeedsAndOr(t *testing.T) { + stdout, stderr, code, err := runNegationVulnHuntScript(t, "! exit 0 | exit 4\necho pipe=$?\n! false && echo continued\n! true || echo fallback\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "pipe=0\ncontinued\nfallback\n", stdout) +} + +func TestVulnHuntShellFeatureRedirectionChain_NegatedRedirectsStaySandboxedAndRestored(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "data.txt"), []byte("secret\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("outside\n"), 0o644)) + + stdout, stderr, code, err := runNegationVulnHuntScript(t, + "! cat < data.txt\necho allowed_status=$?\ncat < data.txt\n! cat < ../outside/secret.txt\necho blocked_status=$?\ncat < Date: Wed, 20 May 2026 11:19:50 +0200 Subject: [PATCH 12/73] test: add procnet reader vuln hunt tripwires --- analysis/ss_procnet_readers_vuln_hunt_test.go | 105 ++++++++++++++++++ .../procnetroute_vuln_hunt_test.go | 20 ++++ .../procnetsocket_vuln_hunt_linux_test.go | 32 ++++++ .../procnetsocket_vuln_hunt_test.go | 31 ++++++ 4 files changed, 188 insertions(+) create mode 100644 analysis/ss_procnet_readers_vuln_hunt_test.go create mode 100644 builtins/internal/procnetroute/procnetroute_vuln_hunt_test.go create mode 100644 builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go create mode 100644 builtins/internal/procnetsocket/procnetsocket_vuln_hunt_test.go diff --git a/analysis/ss_procnet_readers_vuln_hunt_test.go b/analysis/ss_procnet_readers_vuln_hunt_test.go new file mode 100644 index 00000000..311151b2 --- /dev/null +++ b/analysis/ss_procnet_readers_vuln_hunt_test.go @@ -0,0 +1,105 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// ss-procnet-readers. + +package analysis + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// TestVulnHuntSubsystemSSProcnetReaders_ProcPathsNotUserMutable pins the +// threat-model boundary for the documented /proc/net/* direct-open exception: +// production code may declare the proc root globals at package init, but must +// not later assign to them from CLI flags, env vars, shell state, or any other +// user-facing path. Tests are allowed to mutate these globals for synthetic +// proc fixtures and are intentionally excluded here. +func TestVulnHuntSubsystemSSProcnetReaders_ProcPathsNotUserMutable(t *testing.T) { + root := repoRoot(t) + fset := token.NewFileSet() + + for _, dir := range []string{"cmd", "interp", "builtins"} { + base := filepath.Join(root, dir) + err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if isGoTestFile(path) { + return nil + } + file, parseErr := parser.ParseFile(fset, path, nil, 0) + if parseErr != nil { + return parseErr + } + + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.AssignStmt: + for _, lhs := range node.Lhs { + if procnetPathName(lhs) != "" { + t.Errorf("%s assigns to %s in production code; procnet roots must remain hardcoded outside tests", + fset.Position(lhs.Pos()), procnetPathName(lhs)) + } + } + case *ast.ValueSpec: + for _, name := range node.Names { + if name.Name != "ProcPath" && name.Name != "ProcNetRoutePath" { + continue + } + if !allowedProcnetPathDeclaration(root, path, name.Name) { + t.Errorf("%s declares %s outside the approved procnet root declaration files", + fset.Position(name.Pos()), name.Name) + } + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", base, err) + } + } +} + +func procnetPathName(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + if e.Name == "ProcPath" || e.Name == "ProcNetRoutePath" { + return e.Name + } + case *ast.SelectorExpr: + if e.Sel.Name == "ProcPath" || e.Sel.Name == "ProcNetRoutePath" { + return e.Sel.Name + } + } + return "" +} + +func allowedProcnetPathDeclaration(root, path, name string) bool { + switch name { + case "ProcPath": + return path == filepath.Join(root, "builtins", "ss", "ss_linux.go") + case "ProcNetRoutePath": + return path == filepath.Join(root, "builtins", "ip", "ip.go") + default: + return false + } +} + +func isGoTestFile(path string) bool { + return strings.HasSuffix(path, "_test.go") +} diff --git a/builtins/internal/procnetroute/procnetroute_vuln_hunt_test.go b/builtins/internal/procnetroute/procnetroute_vuln_hunt_test.go new file mode 100644 index 00000000..7b7fa914 --- /dev/null +++ b/builtins/internal/procnetroute/procnetroute_vuln_hunt_test.go @@ -0,0 +1,20 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package procnetroute + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntSubsystemSSProcnetReaders_RejectsTraversalProcPath(t *testing.T) { + _, err := ReadRoutes(context.Background(), "/proc/../tmp") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsafe procPath") +} diff --git a/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go new file mode 100644 index 00000000..a45388b0 --- /dev/null +++ b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go @@ -0,0 +1,32 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +package procnetsocket + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntSubsystemSSProcnetReaders_DevZeroSocketTableIsBounded(t *testing.T) { + dir := t.TempDir() + netDir := filepath.Join(dir, "net") + require.NoError(t, os.MkdirAll(netDir, 0o755)) + require.NoError(t, os.Symlink("/dev/zero", filepath.Join(netDir, "tcp"))) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := ReadTCP4(ctx, dir) + require.Error(t, err) + assert.NotEqual(t, context.DeadlineExceeded, ctx.Err(), "ReadTCP4 hung on /dev/zero instead of hitting the scanner cap") +} diff --git a/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_test.go b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_test.go new file mode 100644 index 00000000..6fad062c --- /dev/null +++ b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_test.go @@ -0,0 +1,31 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package procnetsocket + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntSubsystemSSProcnetReaders_RejectsTraversalProcPath(t *testing.T) { + readers := map[string]func(context.Context, string) ([]SocketEntry, error){ + "tcp4": ReadTCP4, + "tcp6": ReadTCP6, + "udp4": ReadUDP4, + "udp6": ReadUDP6, + "unix": ReadUnix, + } + for name, reader := range readers { + t.Run(name, func(t *testing.T) { + _, err := reader(context.Background(), "/proc/../tmp") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsafe procPath") + }) + } +} From e6373f3c5ec39c54c059388489867a0d0c5df4a6 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 11:20:53 +0200 Subject: [PATCH 13/73] test: cover procnet socket entry cap --- .../procnetsocket_vuln_hunt_linux_test.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go index a45388b0..9199bbdf 100644 --- a/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go +++ b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go @@ -11,6 +11,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -30,3 +31,25 @@ func TestVulnHuntSubsystemSSProcnetReaders_DevZeroSocketTableIsBounded(t *testin require.Error(t, err) assert.NotEqual(t, context.DeadlineExceeded, ctx.Err(), "ReadTCP4 hung on /dev/zero instead of hitting the scanner cap") } + +func TestVulnHuntSubsystemSSProcnetReaders_MaxSocketEntriesCap(t *testing.T) { + dir := t.TempDir() + netDir := filepath.Join(dir, "net") + require.NoError(t, os.MkdirAll(netDir, 0o755)) + + const header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" + const row = " 0: 0100007F:0016 00000000:0000 01 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0\n" + var b strings.Builder + b.Grow(len(header) + (MaxEntries+1)*len(row)) + b.WriteString(header) + for range MaxEntries + 1 { + b.WriteString(row) + } + require.NoError(t, os.WriteFile(filepath.Join(netDir, "tcp"), []byte(b.String()), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := ReadTCP4(ctx, dir) + require.ErrorIs(t, err, ErrMaxEntries) + assert.NotEqual(t, context.DeadlineExceeded, ctx.Err(), "ReadTCP4 hung before enforcing MaxEntries") +} From 726facc5e2d3734654334696953eeeb8edfa635d Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 11:33:07 +0200 Subject: [PATCH 14/73] test: add callctx openfile vuln hunt tripwires --- analysis/callctx_openfile_vuln_hunt_test.go | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 analysis/callctx_openfile_vuln_hunt_test.go diff --git a/analysis/callctx_openfile_vuln_hunt_test.go b/analysis/callctx_openfile_vuln_hunt_test.go new file mode 100644 index 00000000..941d399f --- /dev/null +++ b/analysis/callctx_openfile_vuln_hunt_test.go @@ -0,0 +1,163 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// callctx-openfile. + +package analysis + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +func TestVulnHuntSubsystemCallCtxOpenFile_AllOpenFileCallsReadOnly(t *testing.T) { + walkProductionBuiltins(t, func(path string, fset *token.FileSet, file *ast.File) { + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "OpenFile" { + return true + } + if receiverName(sel.X) == "os" { + return true + } + if len(call.Args) < 3 { + t.Errorf("%s: OpenFile call has fewer than 3 arguments", fset.Position(call.Pos())) + return true + } + if !isOSReadonly(call.Args[2]) { + t.Errorf("%s: builtin OpenFile capability must be called with os.O_RDONLY, got %s", + fset.Position(call.Args[2].Pos()), exprString(call.Args[2])) + } + return true + }) + }) +} + +func TestVulnHuntSubsystemCallCtxOpenFile_OpenFileResultsClosed(t *testing.T) { + walkProductionBuiltins(t, func(path string, fset *token.FileSet, file *ast.File) { + rel := productionBuiltinRelPath(t, path) + reporter := fileLineReporter(fset, rel, func(format string, args ...any) { + t.Errorf(format, args...) + }) + checkFileOpenFileClose(file, reporter) + }) +} + +func TestVulnHuntSubsystemCallCtxOpenFile_DirectFileAPIsAreAllowlisted(t *testing.T) { + directFileAPIs := map[string]bool{ + "Open": true, + "OpenFile": true, + "ReadDir": true, + "ReadFile": true, + "Readlink": true, + "Stat": true, + "Lstat": true, + } + allowedDirectFileAPIs := map[string]map[string]bool{ + "builtins/internal/diskstats/diskstats_linux.go": { + "Open": true, + }, + "builtins/internal/procinfo/procinfo_linux.go": { + "Open": true, "ReadDir": true, "ReadFile": true, "Stat": true, + }, + "builtins/internal/procnetroute/procnetroute_linux.go": { + "Open": true, + }, + "builtins/internal/procnetsocket/procnetsocket_linux.go": { + "Open": true, + }, + "builtins/internal/procsyskernel/procsyskernel.go": { + "OpenFile": true, + }, + } + + walkProductionBuiltins(t, func(path string, fset *token.FileSet, file *ast.File) { + rel := productionBuiltinRelPath(t, path) + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || receiverName(sel.X) != "os" || !directFileAPIs[sel.Sel.Name] { + return true + } + if !allowedDirectFileAPIs[rel][sel.Sel.Name] { + t.Errorf("%s: direct os.%s file API in production builtins must be routed through CallContext or added as a documented hardcoded internal exception", + fset.Position(sel.Pos()), sel.Sel.Name) + } + return true + }) + }) +} + +func walkProductionBuiltins(t *testing.T, visit func(path string, fset *token.FileSet, file *ast.File)) { + t.Helper() + + root := repoRoot(t) + builtinsRoot := filepath.Join(root, "builtins") + fset := token.NewFileSet() + err := filepath.WalkDir(builtinsRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, parseErr := parser.ParseFile(fset, path, nil, 0) + if parseErr != nil { + return parseErr + } + visit(path, fset, file) + return nil + }) + if err != nil { + t.Fatalf("walk production builtins: %v", err) + } +} + +func productionBuiltinRelPath(t *testing.T, path string) string { + t.Helper() + rel, err := filepath.Rel(repoRoot(t), path) + if err != nil { + t.Fatal(err) + } + return filepath.ToSlash(rel) +} + +func receiverName(expr ast.Expr) string { + if id, ok := expr.(*ast.Ident); ok { + return id.Name + } + return "" +} + +func isOSReadonly(expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + return ok && receiverName(sel.X) == "os" && sel.Sel.Name == "O_RDONLY" +} + +func exprString(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.SelectorExpr: + return exprString(e.X) + "." + e.Sel.Name + case *ast.BinaryExpr: + return exprString(e.X) + " " + e.Op.String() + " " + exprString(e.Y) + default: + return "" + } +} From 338723d278cdf7c2a23e5a6d6b3af05300f25970 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 14:21:26 +0200 Subject: [PATCH 15/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] field_splitting: test blocked paths --- interp/field_splitting_vuln_hunt_test.go | 185 +++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 interp/field_splitting_vuln_hunt_test.go diff --git a/interp/field_splitting_vuln_hunt_test.go b/interp/field_splitting_vuln_hunt_test.go new file mode 100644 index 00000000..3b327177 --- /dev/null +++ b/interp/field_splitting_vuln_hunt_test.go @@ -0,0 +1,185 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: field_splitting (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" +) + +func runFieldSplittingVulnHuntScript(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), parseScript(t, script)) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_IFSMetacharactersNotReparsed(t *testing.T) { + stdout, stderr, code, err := runFieldSplittingVulnHuntScript(t, "IFS='|;'\nPAYLOAD='alpha|echo HACKED;beta'\necho $PAYLOAD\necho marker\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "alpha echo HACKED beta\nmarker\n", stdout) +} + +func TestVulnHuntShellFeatureExpansionChain_SplitGlobStaysSandboxed(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "visible.txt"), []byte("ok\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + stdout, stderr, code, err := runFieldSplittingVulnHuntScript(t, "PAT='*.txt'\necho $PAT\nPAT='../outside/*.txt'\necho $PAT\n", allowed, AllowedPaths([]string{allowed})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "visible.txt\n") + assert.Contains(t, stdout, "../outside/*.txt\n") + assert.NotContains(t, stdout, "secret.txt") +} + +func TestVulnHuntShellFeatureParserConfusion_CustomIFSBytesAreLiteral(t *testing.T) { + script := `IFS='\' +S='a\b\c' +for x in $S; do echo "[$x]"; done +IFS=' +' +N='one +two' +for x in $N; do echo "<$x>"; done +` + stdout, stderr, code, err := runFieldSplittingVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "[a]\n[b]\n[c]\n\n\n", stdout) +} + +func TestVulnHuntShellFeatureSubshellIsolation_CommandSubIFSDoesNotLeak(t *testing.T) { + stdout, stderr, code, err := runFieldSplittingVulnHuntScript(t, "IFS=,\nA=$(IFS=:; echo 'x:y')\nfor w in $A; do echo \"[$w]\"; done\nB='p,q'\nfor w in $B; do echo \"<$w>\"; done\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "[x:y]\n

\n\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_OversizedIFSDoesNotRunCommand(t *testing.T) { + large := strings.Repeat("x", MaxVarBytes+1) + stdout, stderr, code, err := runFieldSplittingVulnHuntScript(t, "IFS="+large+" echo SHOULD_NOT_RUN\n", "") + + require.NoError(t, err) + assert.NotEqual(t, 0, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "IFS: value too large") + assert.NotContains(t, stdout, "SHOULD_NOT_RUN") +} + +func TestVulnHuntShellFeatureCompositionAttack_InlineIFSRestoredAfterRead(t *testing.T) { + script := `IFS=: read -r f1 f2 < Date: Wed, 20 May 2026 14:33:12 +0200 Subject: [PATCH 16/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] testcmd: blocked tests --- builtins/testcmd/testcmd_vuln_hunt_test.go | 141 ++++++++++++++++++ .../testcmd/testcmd_vuln_hunt_unix_test.go | 41 +++++ 2 files changed, 182 insertions(+) create mode 100644 builtins/testcmd/testcmd_vuln_hunt_test.go create mode 100644 builtins/testcmd/testcmd_vuln_hunt_unix_test.go diff --git a/builtins/testcmd/testcmd_vuln_hunt_test.go b/builtins/testcmd/testcmd_vuln_hunt_test.go new file mode 100644 index 00000000..0f7039e1 --- /dev/null +++ b/builtins/testcmd/testcmd_vuln_hunt_test.go @@ -0,0 +1,141 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package testcmd_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func TestVulnHuntBuiltinFlagDrivenExploit_FlagLookingOperandsStayData(t *testing.T) { + stdout, stderr, code := runScript(t, `test --no-such-flag; echo unknown=$? +test --; echo dashdash=$? +test -h; echo lone_h=$? +test --help >/dev/null; echo test_help=$? +`, "") + + assert.Equal(t, 0, code) + assert.Equal(t, "unknown=0\ndashdash=0\nlone_h=0\ntest_help=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_BracketHelpRequiresExactSingleArg(t *testing.T) { + stdout, stderr, code := runScript(t, `[ --help ]; echo data_help=$? +[ --not-help; echo missing=$? +`, "") + + assert.Equal(t, 0, code) + assert.Equal(t, "data_help=0\nmissing=2\n", stdout) + assert.Equal(t, "[: missing `]'\n", stderr) +} + +func TestVulnHuntBuiltinResourceExhaustion_RepeatedNegationDepthCapped(t *testing.T) { + script := "test " + strings.Repeat("! ", 200) + `""` + mustNotHang(t, func() { + _, stderr, code := runScript(t, script, "") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "expression too deeply nested") + }) +} + +func TestVulnHuntBuiltinResourceExhaustion_LongLogicalChainDoesNotHang(t *testing.T) { + var b strings.Builder + b.WriteString(`test "x"`) + for i := 0; i < 2000; i++ { + b.WriteString(` -a "x"`) + } + + mustNotHang(t, func() { + _, stderr, code := runScript(t, b.String(), "") + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + }) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_SyntaxDiagnosticsAndExitCodes(t *testing.T) { + stdout, stderr, code := runScript(t, `test ""; echo false=$? +test "x"; echo true=$? +test 1 -eq x; echo badint=$? +test '(' ')'; echo empty_group=$? +test a b c d e; echo extra=$? +[ 1 -eq 1; echo missing_bracket=$? +`, "") + + assert.Equal(t, 0, code) + assert.Equal(t, "false=1\ntrue=0\nbadint=2\nempty_group=2\nextra=2\nmissing_bracket=2\n", stdout) + assert.Contains(t, stderr, "test: x: integer expression expected") + assert.Contains(t, stderr, "test: missing argument") + assert.Contains(t, stderr, "test: too many arguments") + assert.Contains(t, stderr, "[: missing `]'") +} + +func TestVulnHuntBuiltinFileAccessBypass_SymlinkEscapePredicatesStaySandboxed(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + secret := filepath.Join(root, "secret") + assert.NoError(t, os.Mkdir(allowed, 0755)) + assert.NoError(t, os.Mkdir(secret, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(allowed, "inside.txt"), []byte("inside"), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("secret"), 0644)) + if err := os.Symlink("../secret/hidden.txt", filepath.Join(allowed, "escape")); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + stdout, stderr, code := runScript(t, `test -e escape; echo exists=$? +test -f escape; echo regular=$? +test -h escape; echo symlink_h=$? +test -L escape; echo symlink_L=$? +test -e inside.txt; echo inside=$? +`, allowed, interp.AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "exists=1\nregular=1\nsymlink_h=0\nsymlink_L=0\ninside=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinFileAccessBypass_FileCompareOutsideSandboxHasNoExistenceOracle(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + secret := filepath.Join(root, "secret") + assert.NoError(t, os.Mkdir(allowed, 0755)) + assert.NoError(t, os.Mkdir(secret, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(allowed, "newer.txt"), []byte("new"), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(secret, "older.txt"), []byte("old"), 0644)) + + stdout, stderr, code := runScript(t, `test newer.txt -nt ../secret/older.txt; echo right_existing=$? +test newer.txt -nt ../secret/missing.txt; echo right_missing=$? +test ../secret/older.txt -nt newer.txt; echo left_existing=$? +test ../secret/missing.txt -nt newer.txt; echo left_missing=$? +test ../secret/older.txt -ot newer.txt; echo ot_left_existing=$? +test ../secret/missing.txt -ot newer.txt; echo ot_left_missing=$? +`, allowed, interp.AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "right_existing=0\nright_missing=0\nleft_existing=1\nleft_missing=1\not_left_existing=0\not_left_missing=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_StaticSurfaceHasNoContentReadExecNetworkOrProcParsers(t *testing.T) { + srcBytes, err := os.ReadFile("testcmd.go") + assert.NoError(t, err) + src := string(srcBytes) + + assert.NotContains(t, src, "OpenFile(") + assert.NotContains(t, src, "RunCommand") + assert.NotContains(t, src, "Stdin") + assert.NotContains(t, src, "os/exec") + assert.NotContains(t, src, "net/") + assert.NotContains(t, src, "builtins/internal/proc") + assert.NotContains(t, src, "builtins/internal/diskstats") +} diff --git a/builtins/testcmd/testcmd_vuln_hunt_unix_test.go b/builtins/testcmd/testcmd_vuln_hunt_unix_test.go new file mode 100644 index 00000000..cd87bc77 --- /dev/null +++ b/builtins/testcmd/testcmd_vuln_hunt_unix_test.go @@ -0,0 +1,41 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build unix + +package testcmd_test + +import ( + "context" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func TestVulnHuntBuiltinSpecialFiles_AccessPredicatesOnFifoDoNotBlock(t *testing.T) { + dir := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0644); err != nil { + t.Skipf("cannot create FIFO: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + stdout, stderr, code := runScriptCtx(ctx, t, `test -r fifo; echo read=$? +test -w fifo; echo write=$? +test -x fifo; echo exec=$? +test -p fifo; echo pipe=$? +`, dir, interp.AllowedPaths([]string{dir})) + + assert.Equal(t, 0, code) + assert.Equal(t, "read=0\nwrite=0\nexec=1\npipe=0\n", stdout) + assert.Empty(t, stderr) +} From 897999b096db5ff721a18471d35fe63a375a8ff1 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 14:42:05 +0200 Subject: [PATCH 17/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] until_clause: blocked tests --- interp/tests/until_clause_vuln_hunt_test.go | 124 ++++++++++++++++++++ interp/until_clause_vuln_hunt_test.go | 40 +++++++ 2 files changed, 164 insertions(+) create mode 100644 interp/tests/until_clause_vuln_hunt_test.go create mode 100644 interp/until_clause_vuln_hunt_test.go diff --git a/interp/tests/until_clause_vuln_hunt_test.go b/interp/tests/until_clause_vuln_hunt_test.go new file mode 100644 index 00000000..efd41c59 --- /dev/null +++ b/interp/tests/until_clause_vuln_hunt_test.go @@ -0,0 +1,124 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package tests_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func TestVulnHuntShellFeatureExpansionChain_UntilConditionDoesNotReparseExpandedSyntax(t *testing.T) { + stdout, stderr, code := whileRun(t, `PAYLOAD='false; echo HACKED' +until $PAYLOAD; do + echo body + break +done +echo done +`) + + assert.Equal(t, 0, code) + assert.Equal(t, "body\ndone\n", stdout) + assert.NotContains(t, stdout, "HACKED") + assert.Contains(t, stderr, "false;") +} + +func TestVulnHuntShellFeatureExpansionChain_UntilGlobStaysSandboxed(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + secret := filepath.Join(root, "secret") + assert.NoError(t, os.Mkdir(allowed, 0755)) + assert.NoError(t, os.Mkdir(secret, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("secret"), 0644)) + + stdout, stderr, code := testutil.RunScript(t, `PAT='../secret/*' +until echo $PAT; do + echo body + break +done +`, allowed, interp.AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "../secret/*\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_UntilConditionStatusCompositions(t *testing.T) { + stdout, stderr, code := whileRun(t, `i= +until ! [ "$i" != aa ]; do + i="${i}a" + echo "neg:$i" +done +until echo test | grep -q match; do + echo pipe-body + break +done +until true && false; do + echo and-body + break +done +`) + + assert.Equal(t, 0, code) + assert.Equal(t, "neg:a\nneg:aa\npipe-body\nand-body\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureRedirectionChain_UntilInputRedirectOutsideAllowedBlocked(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + secret := filepath.Join(root, "secret") + assert.NoError(t, os.Mkdir(allowed, 0755)) + assert.NoError(t, os.Mkdir(secret, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("secret\n"), 0644)) + + stdout, stderr, code := testutil.RunScript(t, `until false; do + cat + break +done < ../secret/hidden.txt +`, allowed, interp.AllowedPaths([]string{allowed})) + + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.NotContains(t, stdout, "secret") + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stderr, "secret\n") +} + +func TestVulnHuntShellFeatureSignalContext_UntilConditionPipelineCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + _, _, _ = whileRunCtx(ctx, t, `until echo x | grep -q y; do :; done`) + assert.Less(t, time.Since(start), 5*time.Second, "until condition pipeline ignored cancellation") +} + +func TestVulnHuntShellFeatureSignalContext_SubshellInfiniteUntilRespectsCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + _, _, _ = whileRunCtx(ctx, t, `( until false; do :; done )`) + assert.Less(t, time.Since(start), 5*time.Second, "subshell-wrapped until ignored cancellation") +} + +func TestVulnHuntShellFeatureSignalContext_UntilLargeHeredocRedirectCancellation(t *testing.T) { + body := strings.Repeat("x", 256*1024) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + _, _, _ = whileRunCtx(ctx, t, "until false; do cat <<'EOF' >/dev/null\n"+body+"\nEOF\ndone") + assert.Less(t, time.Since(start), 5*time.Second, "until heredoc writer ignored cancellation") +} diff --git a/interp/until_clause_vuln_hunt_test.go b/interp/until_clause_vuln_hunt_test.go new file mode 100644 index 00000000..e03e4fc2 --- /dev/null +++ b/interp/until_clause_vuln_hunt_test.go @@ -0,0 +1,40 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func TestVulnHuntShellFeatureReadonlyBypass_UntilConditionReadonlyAssignmentDoesNotMutate(t *testing.T) { + stdout, stderr := runScriptWithReadonly(t, `until RO_VAR=hacked false; do + echo body=$RO_VAR + break +done +echo after=$RO_VAR +`) + + assert.Contains(t, stderr, "readonly variable") + assert.NotContains(t, stdout, "hacked") + assert.Contains(t, stdout, "after=original") +} + +func TestVulnHuntShellFeatureReadonlyBypass_UntilBodyReadonlyAssignmentDoesNotMutate(t *testing.T) { + stdout, stderr := runScriptWithReadonly(t, `until false; do + RO_VAR=hacked echo body=$RO_VAR + break +done +echo after=$RO_VAR +`) + + assert.Contains(t, stderr, "readonly variable") + assert.NotContains(t, stdout, "hacked") + assert.Contains(t, stdout, "after=original") +} From fbb803bd4b1e0e0306f501905b0b7cf2fbaae88c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 14:50:19 +0200 Subject: [PATCH 18/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] empty_script: blocked tests --- interp/empty_script_vuln_hunt_test.go | 65 +++++++++++ interp/tests/empty_script_vuln_hunt_test.go | 122 ++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 interp/empty_script_vuln_hunt_test.go create mode 100644 interp/tests/empty_script_vuln_hunt_test.go diff --git a/interp/empty_script_vuln_hunt_test.go b/interp/empty_script_vuln_hunt_test.go new file mode 100644 index 00000000..32943c66 --- /dev/null +++ b/interp/empty_script_vuln_hunt_test.go @@ -0,0 +1,65 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func TestVulnHuntShellFeatureDeclaredVsImplemented_EmptyFileClearsStaleExitOnReusedRunner(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + parser := syntax.NewParser() + falseProg, err := parser.Parse(strings.NewReader("false\n"), "") + require.NoError(t, err) + err = r.Run(context.Background(), falseProg) + require.Error(t, err) + assert.Equal(t, uint8(1), r.exit.code) + + emptyProg, err := parser.Parse(strings.NewReader(""), "") + require.NoError(t, err) + err = r.Run(context.Background(), emptyProg) + require.NoError(t, err) + assert.Equal(t, uint8(0), r.exit.code) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_ParseScriptRejectsOversizedWhitespaceOnly(t *testing.T) { + _, err := ParseScript(strings.Repeat(" ", MaxScriptBytes+1), "empty.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") +} + +func TestVulnHuntShellFeatureSignalContext_PreCancelledEmptyScriptReturns(t *testing.T) { + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(""), "") + require.NoError(t, err) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + err = r.Run(ctx, prog) + require.NoError(t, err) + assert.Less(t, time.Since(start), time.Second) +} diff --git a/interp/tests/empty_script_vuln_hunt_test.go b/interp/tests/empty_script_vuln_hunt_test.go new file mode 100644 index 00000000..69f9e7e5 --- /dev/null +++ b/interp/tests/empty_script_vuln_hunt_test.go @@ -0,0 +1,122 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package tests_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func TestVulnHuntShellFeatureExpansionChain_EmptySubstitutionAndCommandStayNoop(t *testing.T) { + stdout, stderr, code := testutil.RunScript(t, `false +echo "empty=[$()] status=$?" +EMPTY= +$EMPTY +echo "after_empty_cmd=$?" +`, "") + + assert.Equal(t, 0, code) + assert.Equal(t, "empty=[] status=1\nafter_empty_cmd=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_EmptyAndCommentInputsStayNoop(t *testing.T) { + cases := []string{ + "", + " \t \n\n", + "# comment with $(echo hacked) and `backticks`\n# > /tmp/out\n", + "# windows comment\r\n# another\r\n", + } + for _, script := range cases { + t.Run("", func(t *testing.T) { + stdout, stderr, code := testutil.RunScript(t, script, "") + assert.Equal(t, 0, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + }) + } +} + +func TestVulnHuntShellFeatureParserConfusion_NULInputFailsSafely(t *testing.T) { + stdout, stderr, code := testutil.RunScript(t, "\x00", "") + assert.Equal(t, 0, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureSubshellIsolation_EmptySubshellsFailAtParseTime(t *testing.T) { + parser := syntax.NewParser() + _, err := parser.Parse(strings.NewReader("()"), "") + require.Error(t, err) + + _, err = parser.Parse(strings.NewReader("( # comment\n )"), "") + require.Error(t, err) +} + +func TestVulnHuntShellFeatureCompositionAttack_CommentLineContinuationsDoNotCreateCommands(t *testing.T) { + stdout, stderr, code := testutil.RunScript(t, `# comment ending with backslash \ +echo visible +# another comment +`, "") + + assert.Equal(t, 0, code) + assert.Equal(t, "visible\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureRedirectionChain_RedirectionOnlyStatementRestoresStdio(t *testing.T) { + stdout, stderr, code := testutil.RunScript(t, `>/dev/null +echo visible +2>/dev/null +echo err-visible >&2 +`, "") + + assert.Equal(t, 0, code) + assert.Equal(t, "visible\n", stdout) + assert.Equal(t, "err-visible\n", stderr) +} + +func TestVulnHuntShellFeatureRedirectionChain_InputRedirectOnlyDoesNotLeakToNextCommand(t *testing.T) { + dir := t.TempDir() + assert.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("secret\n"), 0644)) + + stdout, stderr, code := testutil.RunScript(t, `< input.txt +cat +`, dir, interp.AllowedPaths([]string{dir})) + + assert.Equal(t, 0, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureRedirectionChain_InputRedirectOnlyOutsideSandboxBlocked(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + secret := filepath.Join(root, "secret") + assert.NoError(t, os.Mkdir(allowed, 0755)) + assert.NoError(t, os.Mkdir(secret, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("secret\n"), 0644)) + + stdout, stderr, code := testutil.RunScript(t, `< ../secret/hidden.txt +cat +`, allowed, interp.AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "secret") +} From 656a8420945c195b7416c54d4bda70af621a72af Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 14:58:27 +0200 Subject: [PATCH 19/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] ping: test blocked attacks --- builtins/ping/ping_vuln_hunt_test.go | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 builtins/ping/ping_vuln_hunt_test.go diff --git a/builtins/ping/ping_vuln_hunt_test.go b/builtins/ping/ping_vuln_hunt_test.go new file mode 100644 index 00000000..6e545af7 --- /dev/null +++ b/builtins/ping/ping_vuln_hunt_test.go @@ -0,0 +1,52 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Vulnerability-hunt regression tests for campaign 2026-05-20-gpt-5.5-cyber-3. +// These tests pin blocked attack paths only. Working exploit PoCs remain in the +// private vuln-hunt repository until a fix ships. + +package ping_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestVulnHuntBuiltinIntegerOverflow_CountParseOverflowRejected(t *testing.T) { + stdout, stderr, code := cmdRun(t, "ping -c 999999999999999999999999999999 127.0.0.1") + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "invalid argument") +} + +func TestVulnHuntBuiltinIntegerOverflow_InvalidDurationsWithoutHelpRejected(t *testing.T) { + for _, script := range []string{ + "ping -W NaN 127.0.0.1", + "ping -i +Inf 127.0.0.1", + "ping -W 1e20 127.0.0.1", + "ping -i -1s 127.0.0.1", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, script) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "invalid argument") + }) + } +} + +func TestVulnHuntBuiltinResourceExhaustion_LongHostnameContextBounded(t *testing.T) { + host := strings.Repeat("a", 10000) + ".invalid" + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, _, code := runScriptCtx(ctx, t, "ping -c 1 -- "+host) + assert.Equal(t, 1, code) + assert.NoError(t, ctx.Err(), "long hostname must fail before the context deadline") +} From 10642d29cae2422153d02bbbd5ecb2b7ec4eb9bf Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 15:06:06 +0200 Subject: [PATCH 20/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] ls: test blocked attacks --- builtins/ls/ls_vuln_hunt_test.go | 57 +++++++++++++++++++++++++++ builtins/ls/ls_vuln_hunt_unix_test.go | 29 ++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 builtins/ls/ls_vuln_hunt_test.go create mode 100644 builtins/ls/ls_vuln_hunt_unix_test.go diff --git a/builtins/ls/ls_vuln_hunt_test.go b/builtins/ls/ls_vuln_hunt_test.go new file mode 100644 index 00000000..f4953233 --- /dev/null +++ b/builtins/ls/ls_vuln_hunt_test.go @@ -0,0 +1,57 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Vulnerability-hunt regression tests for campaign 2026-05-20-gpt-5.5-cyber-3. +// These tests pin blocked attack paths only. Working exploit PoCs remain in the +// private vuln-hunt repository until a fix ships. + +package ls_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntBuiltinFlagDrivenExploit_InvalidPaginationBeforeHelpRejected(t *testing.T) { + dir := t.TempDir() + for _, script := range []string{ + "ls --offset nope --help", + "ls --limit 999999999999999999999999999999 --help", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := lsRun(t, script, dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "ls:") + assert.NotContains(t, stdout, "Usage: ls") + }) + } +} + +func TestVulnHuntBuiltinFileAccessBypass_SymlinkToOutsideDirectoryNotListed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires privileges on some Windows setups") + } + allowed := t.TempDir() + forbidden := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("secret"), 0o644)) + require.NoError(t, os.Symlink(forbidden, filepath.Join(allowed, "escape"))) + + stdout, stderr, code := lsRun(t, "ls escape", allowed) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "escape\n", stdout) + + stdout, stderr, code = lsRun(t, "ls escape/", allowed) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "escape/\n", stdout) + assert.NotContains(t, stdout, "secret.txt") +} diff --git a/builtins/ls/ls_vuln_hunt_unix_test.go b/builtins/ls/ls_vuln_hunt_unix_test.go new file mode 100644 index 00000000..d218cbe5 --- /dev/null +++ b/builtins/ls/ls_vuln_hunt_unix_test.go @@ -0,0 +1,29 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package ls_test + +import ( + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntBuiltinSpecialFiles_FifoLongFormatMetadataOnly(t *testing.T) { + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "fifo"), 0o644)) + + mustNotHang(t, func() { + stdout, stderr, code := lsRun(t, "ls -lF fifo", dir) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "fifo|") + }) +} From 80e58340f82304d9a53144516e646cfa7ef24c50 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 15:20:28 +0200 Subject: [PATCH 21/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] simple_command: test blocked attacks --- .../simple_command_readonly_vuln_hunt_test.go | 28 +++ interp/simple_command_vuln_hunt_test.go | 162 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 interp/simple_command_readonly_vuln_hunt_test.go create mode 100644 interp/simple_command_vuln_hunt_test.go diff --git a/interp/simple_command_readonly_vuln_hunt_test.go b/interp/simple_command_readonly_vuln_hunt_test.go new file mode 100644 index 00000000..b7b9f657 --- /dev/null +++ b/interp/simple_command_readonly_vuln_hunt_test.go @@ -0,0 +1,28 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: simple_command) + +package interp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVulnHuntShellFeatureReadonlyBypass_FailedInlineReadonlyDoesNotMutateViaLaterSubst(t *testing.T) { + stdout, stderr := runScriptWithReadonly(t, + "RO_VAR=evil FOO=$(echo SIDE_EFFECT >&2) echo HIT\necho after foo=$FOO ro=$RO_VAR\n") + + assert.Contains(t, stderr, "readonly variable", + "readonly inline assignment must fail visibly") + assert.Contains(t, stderr, "SIDE_EFFECT", + "later assignment-value command substitutions still run during expansion") + assert.NotContains(t, stdout, "HIT", + "the command body must not execute after a readonly inline assignment failure") + assert.Contains(t, stdout, "after foo= ro=original", + "sibling inline assignments must be restored and readonly value must remain unchanged") +} diff --git a/interp/simple_command_vuln_hunt_test.go b/interp/simple_command_vuln_hunt_test.go new file mode 100644 index 00000000..be5bddf2 --- /dev/null +++ b/interp/simple_command_vuln_hunt_test.go @@ -0,0 +1,162 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: simple_command) + +package interp_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func runSimpleCommandVulnHunt(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + + prog, err := interp.ParseScript(script, "simple_command_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &stdout, &stderr)}, opts...) + r, err := interp.New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected Run error: %v", err) + } + } + return stdout.String(), stderr.String(), exitCode +} + +func TestVulnHuntShellFeatureExpansionChain_UnquotedCommandExpansionNoReparse(t *testing.T) { + stdout, stderr, code := runSimpleCommandVulnHunt(t, "PAYLOAD='echo SAFE; echo HACKED'\n$PAYLOAD\n", + interpoption.AllowAllCommands().(interp.RunnerOption)) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "SAFE; echo HACKED\n", stdout, + "expanded semicolon must remain an argv byte, not become a second command") +} + +func TestVulnHuntShellFeatureParserConfusion_AssignmentVariantsRejectedBeforeDispatch(t *testing.T) { + for _, tc := range []struct { + name string + script string + wantStderr string + parseError bool + }{ + {"append", "A+=x echo BAD\n", "+= is not supported", false}, + {"indexed", "A[0]=x echo BAD\n", "inline variables cannot be arrays", true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.parseError { + _, err := interp.ParseScript(tc.script, "simple_command_vuln_hunt.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + + stdout, stderr, code := runSimpleCommandVulnHunt(t, tc.script, + interpoption.AllowAllCommands().(interp.RunnerOption)) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.wantStderr) + assert.NotContains(t, stdout, "BAD") + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_AssignmentAndCmdSubstDoNotLeak(t *testing.T) { + stdout, stderr, code := runSimpleCommandVulnHunt(t, strings.Join([]string{ + "X=outer", + "( X=inner; Y=subshell )", + "Z=$(LEAK=inside; echo value)", + `echo "$X|$Y|$LEAK|$Z"`, + "", + }, "\n"), interpoption.AllowAllCommands().(interp.RunnerOption)) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "outer|||value\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_AllowedCommandsFinalNameExact(t *testing.T) { + stdout, stderr, code := runSimpleCommandVulnHunt(t, "IFS=/\nCMD=/cat\n$CMD secret.txt\n", + interp.AllowedCommands([]string{"rshell:echo"})) + + assert.Equal(t, 127, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "rshell: cat: command not allowed") +} + +func TestVulnHuntShellFeatureCompositionAttack_InvalidRedirectPreventsEarlierSideEffects(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := interp.New(interp.StdIO(nil, &stdout, &stderr), interpoption.AllowAllCommands().(interp.RunnerOption)) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + invalid, err := interp.ParseScript("A=changed\necho hi > \"$TARGET\"\n", "invalid_redirect.sh") + require.NoError(t, err) + err = r.Run(context.Background(), invalid) + require.Error(t, err) + var es interp.ExitStatus + require.True(t, errors.As(err, &es)) + assert.Equal(t, interp.ExitStatus(2), es) + assert.Contains(t, stderr.String(), "> file redirection is not supported") + + stdout.Reset() + stderr.Reset() + check, err := interp.ParseScript(`echo "[$A]"`, "check.sh") + require.NoError(t, err) + err = r.Run(context.Background(), check) + require.NoError(t, err) + assert.Equal(t, "[]\n", stdout.String(), + "whole-file validation must prevent assignments before a later invalid redirect from taking effect") + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureCompositionAttack_RedirectRestoredAfterFailedInlineAssignment(t *testing.T) { + large := strings.Repeat("x", interp.MaxVarBytes+1) + script := "BIG=" + large + " echo BAD >/dev/null\necho VISIBLE\n" + + stdout, stderr, code := runSimpleCommandVulnHunt(t, script, + interpoption.AllowAllCommands().(interp.RunnerOption)) + + assert.Equal(t, 0, code) + assert.Equal(t, "VISIBLE\n", stdout) + assert.Contains(t, stderr, "BIG: value too large") + assert.NotContains(t, stdout, "BAD") +} + +func TestVulnHuntShellFeatureRedirectionChain_DynamicRedirectTargetsRejected(t *testing.T) { + for _, script := range []string{ + "TARGET=/dev/null\necho hi > \"$TARGET\"\n", + "echo hi > /dev/nul?\n", + } { + stdout, stderr, code := runSimpleCommandVulnHunt(t, script, + interpoption.AllowAllCommands().(interp.RunnerOption)) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "file redirection is not supported") + } +} From ae3ff6636e3683d371cf486e91b2d1872b7c51ae Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 15:33:30 +0200 Subject: [PATCH 22/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] environment: test blocked attacks --- interp/environment_vuln_hunt_test.go | 168 +++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 interp/environment_vuln_hunt_test.go diff --git a/interp/environment_vuln_hunt_test.go b/interp/environment_vuln_hunt_test.go new file mode 100644 index 00000000..9eca1c3e --- /dev/null +++ b/interp/environment_vuln_hunt_test.go @@ -0,0 +1,168 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: environment) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runEnvironmentVulnHunt(t *testing.T, script string, opts ...RunnerOption) (string, string, int) { + t.Helper() + + prog := parseScript(t, script) + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr)}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var es ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected Run error: %v", err) + } + } + return stdout.String(), stderr.String(), exitCode +} + +func TestVulnHuntShellFeatureExpansionChain_EnvMetacharactersNotReparsed(t *testing.T) { + stdout, stderr, code := runEnvironmentVulnHunt(t, "$PAYLOAD\n", + Env("PAYLOAD=echo SAFE; echo HACKED"), + allowAllCommandsOpt(), + ) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "SAFE; echo HACKED\n", stdout) +} + +func TestVulnHuntShellFeatureParserConfusion_EnvQuestionDoesNotOverrideLastStatus(t *testing.T) { + stdout, stderr, code := runEnvironmentVulnHunt(t, "false\necho \"$?\"\n", + Env("?=99"), + allowAllCommandsOpt(), + ) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "1\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_NoHostEnvInherited(t *testing.T) { + t.Setenv("RSHELL_VULN_HUNT_SECRET", "SHOULD_NOT_LEAK") + + stdout, stderr, code := runEnvironmentVulnHunt(t, "echo \"secret=$RSHELL_VULN_HUNT_SECRET\"\n", + allowAllCommandsOpt(), + ) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "secret=\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_PwdEnvDoesNotDriveSandboxPathResolution(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "file.txt"), []byte("allowed\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "file.txt"), []byte("secret\n"), 0o644)) + + script := "PWD=" + shellQuoteForEnvironment(outside) + "\ncat file.txt\npwd\n" + stdout, stderr, code := runEnvironmentVulnHunt(t, script, + AllowedPaths([]string{allowed}), + allowAllCommandsOpt(), + ) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "allowed\n") + assert.NotContains(t, stdout, "secret") + assert.Contains(t, stdout, allowed) +} + +func TestVulnHuntShellFeatureCompositionAttack_EnvRedirectOperandStillSandboxed(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "input.txt"), []byte("allowed\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + script := "P=" + shellQuoteForEnvironment("../outside/secret.txt") + "\ncat < $P\necho status=$?\nP=input.txt\ncat < $P\n" + stdout, stderr, code := runEnvironmentVulnHunt(t, script, + AllowedPaths([]string{allowed}), + allowAllCommandsOpt(), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\nallowed\n", stdout) + assert.Contains(t, stderr, "secret.txt") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureRedirectionChain_TildeRedirectTargetsBlocked(t *testing.T) { + for _, tc := range []struct { + script string + want string + }{ + {"cat < ~/secret\n", "tilde expansion is not supported"}, + {"echo hi > ~/out\n", "file redirection is not supported"}, + } { + stdout, stderr, code := runEnvironmentVulnHunt(t, tc.script, allowAllCommandsOpt()) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.want) + } +} + +func TestVulnHuntShellFeatureSignalContext_EnvSplitLoopRespectsCancellation(t *testing.T) { + r := newTimeoutRunner(t, MaxExecutionTime(100*time.Millisecond)) + start := time.Now() + + err := r.Run(context.Background(), parseScript(t, "ITEMS='a b'\nfor item in $ITEMS; do while true; do :; done; done\n")) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 5*time.Second) +} + +func TestVulnHuntShellFeatureSubshellIsolation_BackgroundEnvSnapshot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("pipeline timing semantics in this test use POSIX-style shell snippets") + } + stdout, stderr, code := runEnvironmentVulnHunt(t, + "X=parent\nprintf x | { read _; echo pipe=$X; X=pipe; }\necho parent=$X\n", + allowAllCommandsOpt(), + ) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "pipe=parent\nparent=parent\n", stdout) +} + +func shellQuoteForEnvironment(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} From 0f72b99d3b4505e4c0261bad90651cf8be4c233e Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 15:45:33 +0200 Subject: [PATCH 23/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] redirections: test blocked attacks --- interp/tests/redirections_vuln_hunt_test.go | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 interp/tests/redirections_vuln_hunt_test.go diff --git a/interp/tests/redirections_vuln_hunt_test.go b/interp/tests/redirections_vuln_hunt_test.go new file mode 100644 index 00000000..c2573d3d --- /dev/null +++ b/interp/tests/redirections_vuln_hunt_test.go @@ -0,0 +1,121 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: redirections) + +package tests_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntShellFeatureExpansionChain_InputRedirectOperandRemainsSingleLiteral(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a b.txt extra"), []byte("single-path\n"), 0o644)) + + stdout, stderr, code := redirRun(t, "P='a b.txt extra'\ncat < $P\n", dir) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "single-path\n", stdout) +} + +func TestVulnHuntShellFeatureExpansionChain_CommandSubstRedirectTargetStillSandboxed(t *testing.T) { + allowed := t.TempDir() + outside := t.TempDir() + secret := filepath.Join(outside, "secret.txt") + require.NoError(t, os.WriteFile(secret, []byte("secret\n"), 0o644)) + + script := "cat < $(printf %s " + quoteRedirectionVulnHunt(secret) + ")\necho status=$?\n" + stdout, stderr, code := redirRun(t, script, allowed) + + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureParserConfusion_NullDeviceVariantsRemainBlocked(t *testing.T) { + dir := t.TempDir() + for _, script := range []string{ + "echo hi > /dev/./null\n", + "echo hi > /dev//null\n", + "echo hi > /dev/null/\n", + "echo hi > /dev/null/../null\n", + "echo hi > '/dev/null'\n", + } { + stdout, stderr, code := pentestRedirRun(t, script, dir) + + assert.Equal(t, 2, code, "script=%q", script) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "file redirection is not supported") + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_BraceRedirectRestoresPipeStdin(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("file\n"), 0o644)) + + stdout, stderr, code := redirRun(t, "printf 'pipe\\n' | { cat < data.txt; cat; }\n", dir) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "file\npipe\n", stdout) +} + +func TestVulnHuntShellFeatureCompositionAttack_FdDupOrderMatchesBash(t *testing.T) { + dir := t.TempDir() + + stdout1, stderr1, code1 := redirRun(t, "cat missing 2>&1 >/dev/null\n", dir) + assert.Equal(t, 1, code1) + assert.NotEmpty(t, stdout1, "stderr duplicated to the original stdout before stdout moved to /dev/null") + assert.Empty(t, stderr1) + + stdout2, stderr2, code2 := redirRun(t, "cat missing >/dev/null 2>&1\n", dir) + assert.Equal(t, 1, code2) + assert.Empty(t, stdout2) + assert.Empty(t, stderr2) +} + +func TestVulnHuntShellFeatureRedirectionChain_MixedAllowedBlockedRedirectFailsBeforeExecution(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := redirRunNoAllowed(t, "echo before\necho data >/dev/null > out.txt\necho after\n", dir) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "> file redirection is not supported") + require.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntShellFeatureReadonlyBypass_RedirectOperandCommandSubstCannotDeclare(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("data\n"), 0o644)) + + stdout, stderr, code := redirRun(t, "cat < $(readonly X=1; printf data.txt)\n", dir) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "readonly is not supported") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_MaxScriptBytesRejectsLargeHeredoc(t *testing.T) { + body := strings.Repeat("x", interp.MaxScriptBytes+1) + _, err := interp.ParseScript("cat < Date: Wed, 20 May 2026 15:50:44 +0200 Subject: [PATCH 24/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] case_clause: test blocked attacks --- interp/tests/case_clause_vuln_hunt_test.go | 189 +++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 interp/tests/case_clause_vuln_hunt_test.go diff --git a/interp/tests/case_clause_vuln_hunt_test.go b/interp/tests/case_clause_vuln_hunt_test.go new file mode 100644 index 00000000..b201333e --- /dev/null +++ b/interp/tests/case_clause_vuln_hunt_test.go @@ -0,0 +1,189 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: case_clause) + +package tests_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func runCaseClauseVulnHunt(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + prog, err := interp.ParseScript(script, "case_clause_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]interp.RunnerOption{ + interp.StdIO(nil, &stdout, &stderr), + interpoption.AllowAllCommands().(interp.RunnerOption), + }, opts...) + r, err := interp.New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected Run error: %v", err) + } + } + return stdout.String(), stderr.String(), exitCode +} + +func TestVulnHuntShellFeatureExpansionChain_CaseKeywordExpansionNotReparsed(t *testing.T) { + for _, script := range []string{ + "KW=case\n$KW\n", + "PAYLOAD=$(printf 'case x in x) echo BAD;; esac')\n$PAYLOAD\n", + } { + stdout, stderr, code := runCaseClauseVulnHunt(t, script) + + assert.Equal(t, 127, code, "script=%q", script) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "unknown command") + assert.NotContains(t, stdout+stderr, "BAD") + } +} + +func TestVulnHuntShellFeatureParserConfusion_CaseFormsAllBlocked(t *testing.T) { + for _, script := range []string{ + "case x in x) echo BAD;; esac\n", + "case x in (x) echo BAD;; esac\n", + "case b in [abc]) echo BAD;; esac\n", + "case hello in hi|hello) echo BAD;; esac\n", + "case x in *) echo BAD;; esac\n", + "case x in x) ;; esac\n", + "case x in x) echo BAD;& esac\n", + "case x in x) echo BAD;;& esac\n", + "case x in\r\n x) echo BAD;;\r\nesac\r\n", + } { + stdout, stderr, code := runCaseClauseVulnHunt(t, script) + + assert.Equal(t, 2, code, "script=%q", script) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) + } +} + +func TestVulnHuntShellFeatureExpansionChain_CaseSubjectPatternAndBodyNotExpanded(t *testing.T) { + script := "case $(echo SUBJECT >&2) in $(echo PATTERN >&2)) echo BODY;; *) echo DEFAULT;; esac\n" + stdout, stderr, code := runCaseClauseVulnHunt(t, script) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) + assert.NotContains(t, stderr, "SUBJECT") + assert.NotContains(t, stderr, "PATTERN") +} + +func TestVulnHuntShellFeatureSubshellIsolation_NestedCaseDoesNotRunNeighbors(t *testing.T) { + for _, script := range []string{ + "(case x in x) X=leak;; esac); echo after=$X\n", + "echo left | case x in x) cat;; esac\n", + "{ case x in x) echo BAD;; esac; echo after; }\n", + "X=$(case x in x) echo leak;; esac)\necho after=$X\n", + } { + stdout, stderr, code := runCaseClauseVulnHunt(t, script) + + assert.Equal(t, 2, code, "script=%q", script) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) + } +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_WholeFileValidationPreventsSideEffects(t *testing.T) { + stdout, stderr, code := runCaseClauseVulnHunt(t, "echo before\ncase x in x) echo BAD;; esac\necho after\n") + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) +} + +func TestVulnHuntShellFeatureCompositionAttack_CaseRedirectionsNeverApply(t *testing.T) { + for _, script := range []string{ + "case x in x) echo BAD;; esac > out.txt\n", + "case x in x) echo BAD;; esac < missing.txt\n", + "case x in x) cat > out.txt;; esac\n", + } { + stdout, stderr, code := runCaseClauseVulnHunt(t, script) + + assert.Equal(t, 2, code, "script=%q", script) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) + } +} + +func TestVulnHuntShellFeatureRedirectionChain_CaseHeredocDoesNotFeedStdin(t *testing.T) { + stdout, stderr, code := runCaseClauseVulnHunt(t, "case x in x) cat 3< Date: Wed, 20 May 2026 16:03:25 +0200 Subject: [PATCH 25/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] help: blocked tests --- builtins/tests/help/help_vuln_hunt_test.go | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 builtins/tests/help/help_vuln_hunt_test.go diff --git a/builtins/tests/help/help_vuln_hunt_test.go b/builtins/tests/help/help_vuln_hunt_test.go new file mode 100644 index 00000000..aa3476a0 --- /dev/null +++ b/builtins/tests/help/help_vuln_hunt_test.go @@ -0,0 +1,144 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package help_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins" + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +// Campaign: 2026-05-20-gpt-5.5-cyber-3 + +func allowAllCommands() interp.RunnerOption { + return interpoption.AllowAllCommands().(interp.RunnerOption) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_HelpRejectsMalformedAndUnknownFlags(t *testing.T) { + stdout, stderr, code := runScript(t, `help --all=maybe; echo all=$? +help --help=maybe; echo help=$? +help --verbose; echo verbose=$? +`, "", allowAllCommands()) + + assert.Equal(t, 0, code) + assert.Equal(t, "all=1\nhelp=1\nverbose=1\n", stdout) + assert.Contains(t, stderr, `help: invalid argument "maybe" for "--all" flag`) + assert.Contains(t, stderr, `help: invalid argument "maybe" for "--help" flag`) + assert.Contains(t, stderr, "help: unrecognized option '--verbose'") +} + +func TestVulnHuntBuiltinFlagDrivenExploit_TrailingHelpUsesSharedHelpTrim(t *testing.T) { + stdout, stderr, code := runScript(t, "help missing-topic --help; echo status=$?", "", allowAllCommands()) + + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: help [--all] [feature|command]\n") + assert.True(t, strings.HasSuffix(stdout, "status=0\n")) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinResourceExhaustion_ManyAllowedPathsStayBelowOutputLimit(t *testing.T) { + base := t.TempDir() + paths := make([]string, 0, 128) + for i := 0; i < 128; i++ { + p := filepath.Join(base, fmt.Sprintf("root-%03d", i)) + require.NoError(t, os.Mkdir(p, 0755)) + paths = append(paths, p) + } + + stdout, stderr, code := runScript(t, "help --all", "", + allowAllCommands(), + interp.AllowedPaths(paths), + ) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Less(t, len(stdout), 1024*1024) + assert.Contains(t, stdout, "\nAllowed paths:\n") + assert.Contains(t, stdout, "\n "+paths[0]+"\n") + assert.Contains(t, stdout, "\n "+paths[len(paths)-1]+"\n") +} + +func TestVulnHuntBuiltinResourceExhaustion_CommandTopicHelpIsFiniteAndNonExecuting(t *testing.T) { + _, _ = interp.New(allowAllCommands()) + + for _, name := range builtins.Names() { + stdout, stderr, code := runScript(t, "help "+name, "", allowAllCommands()) + assert.Equalf(t, 0, code, "help %s", name) + assert.Emptyf(t, stderr, "help %s", name) + assert.NotEmptyf(t, stdout, "help %s", name) + assert.Lessf(t, len(stdout), 64*1024, "help %s", name) + } + + stdout, stderr, code := runScript(t, "help exit; echo still=$?", "", allowAllCommands()) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "exit:") + assert.True(t, strings.HasSuffix(stdout, "still=0\n")) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_TopicPolicyAndAllowedPathsHook(t *testing.T) { + stdout, stderr, code := runScript(t, "help cat", "", + interp.AllowedCommands([]string{"rshell:help"}), + ) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "help: no help topics match 'cat'\n") + assert.NotContains(t, stderr, "concatenate and print files") + + stdout, stderr, code = runScript(t, "help variables", "", + interp.AllowedCommands([]string{"rshell:help"}), + ) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "variables - Assignments") + assert.Empty(t, stderr) + + realRoot := filepath.Join(t.TempDir(), "real-root") + require.NoError(t, os.Mkdir(realRoot, 0755)) + + stdout, stderr, code = runScript(t, "ALLOWED_PATHS=/fake help", "", + allowAllCommands(), + interp.AllowedPaths([]string{realRoot}), + ) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "\n "+realRoot+"\n") + assert.NotContains(t, stdout, "/fake") + + stdout, stderr, code = runScript(t, "ALLOWED_PATHS=/fake help", "", allowAllCommands()) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "Allowed paths:") + assert.Contains(t, stdout, "(no allowed paths configured") + assert.NotContains(t, stdout, "/fake") +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_StaticSurfaceHasNoFileNetworkExecOrProcParsers(t *testing.T) { + srcBytes, err := os.ReadFile(filepath.Join("..", "..", "help", "help.go")) + require.NoError(t, err) + src := string(srcBytes) + + assert.NotContains(t, src, `"os"`) + assert.NotContains(t, src, "os.") + assert.NotContains(t, src, "OpenFile(") + assert.NotContains(t, src, "ReadFile(") + assert.NotContains(t, src, "ReadDir(") + assert.NotContains(t, src, "Stat(") + assert.NotContains(t, src, "Lstat(") + assert.NotContains(t, src, "RunCommand") + assert.NotContains(t, src, "os/exec") + assert.NotContains(t, src, "net/") + assert.NotContains(t, src, "builtins/internal/proc") + assert.NotContains(t, src, "builtins/internal/diskstats") +} From b3b3a2f4c8f791aaf2373f56ad71ca11b11867f7 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 16:18:14 +0200 Subject: [PATCH 26/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] allowed_paths: blocked tests --- interp/allowed_paths_vuln_hunt_test.go | 186 +++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 interp/allowed_paths_vuln_hunt_test.go diff --git a/interp/allowed_paths_vuln_hunt_test.go b/interp/allowed_paths_vuln_hunt_test.go new file mode 100644 index 00000000..d930d333 --- /dev/null +++ b/interp/allowed_paths_vuln_hunt_test.go @@ -0,0 +1,186 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: allowed_paths) + +package interp + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runAllowedPathsVulnHunt(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), parseScript(t, script)) + exitCode := 0 + if err != nil { + var es ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected Run error: %v", err) + } + } + return stdout.String(), stderr.String(), exitCode +} + +func TestVulnHuntShellFeatureExpansionChain_AllowedPathsEnvCannotBroadenSandbox(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "safe.txt"), []byte("safe\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + script := strings.Join([]string{ + "ALLOWED_PATHS=" + shellQuoteForAllowedPathsVH(outside), + "PWD=" + shellQuoteForAllowedPathsVH(outside), + "cat ../outside/secret.txt", + "echo status=$?", + "cat safe.txt", + }, "\n") + "\n" + stdout, stderr, code := runAllowedPathsVulnHunt(t, script, allowed, AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\nsafe\n", stdout) + assert.Contains(t, stderr, "secret.txt") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureParserConfusion_DotSegmentsAndDotDotNames(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.MkdirAll(filepath.Join(allowed, "sub"), 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "sub", "file.txt"), []byte("nested\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "..data"), []byte("dotdot-name\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + script := strings.Join([]string{ + "cat sub/../sub/file.txt", + "cat ..data", + "cat ../outside/secret.txt", + "echo outside=$?", + }, "\n") + "\n" + stdout, stderr, code := runAllowedPathsVulnHunt(t, script, allowed, AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "nested\ndotdot-name\noutside=1\n", stdout) + assert.Contains(t, stderr, "secret.txt") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureSubshellIsolation_ChildEnvMutationDoesNotBroadenParent(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "safe.txt"), []byte("safe\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + script := strings.Join([]string{ + "( ALLOWED_PATHS=" + shellQuoteForAllowedPathsVH(outside) + "; PWD=" + shellQuoteForAllowedPathsVH(outside) + "; cat ../outside/secret.txt; echo child=$? )", + "cat safe.txt", + "echo parent=$ALLOWED_PATHS", + }, "\n") + "\n" + stdout, stderr, code := runAllowedPathsVulnHunt(t, script, allowed, AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "child=1\nsafe\nparent="+allowed+"\n", stdout) + assert.Contains(t, stderr, "secret.txt") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_AllSkippedRootsStillBlockFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "blocked.txt"), []byte("blocked\n"), 0o644)) + var stdout, stderr, warnings bytes.Buffer + + r, err := New( + StdIO(nil, &stdout, &stderr), + WarningsWriter(&warnings), + allowAllCommandsOpt(), + AllowedPaths([]string{filepath.Join(dir, "missing")}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + + err = r.Run(context.Background(), parseScript(t, "cat blocked.txt\necho read=$?\necho paths=$ALLOWED_PATHS\n")) + require.NoError(t, err) + + assert.Equal(t, "read=1\npaths=\n", stdout.String()) + assert.Contains(t, stderr.String(), "permission denied") + assert.NotContains(t, stdout.String(), "blocked") + assert.Contains(t, warnings.String(), "AllowedPaths: skipping") + assert.NotContains(t, stdout.String(), "AllowedPaths: skipping") +} + +func TestVulnHuntShellFeatureCompositionAttack_RedirectFailureRestoresStdin(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "safe.txt"), []byte("safe\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + script := "cat < ../outside/secret.txt\necho status=$?\ncat < safe.txt\ncat <<'EOF'\nheredoc\nEOF\n" + stdout, stderr, code := runAllowedPathsVulnHunt(t, script, allowed, AllowedPaths([]string{allowed})) + + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\nsafe\nheredoc\n", stdout) + assert.Contains(t, stderr, "secret.txt") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureSignalContext_GlobLoopRespectsCancellation(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644)) + r, err := New( + StdIO(nil, io.Discard, io.Discard), + allowAllCommandsOpt(), + AllowedPaths([]string{dir}), + MaxExecutionTime(100*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + start := time.Now() + + err = r.Run(context.Background(), parseScript(t, "while true; do for f in *.txt; do :; done; done\n")) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 5*time.Second) +} + +func shellQuoteForAllowedPathsVH(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} From 60832e4b00f07c12c74e9850e9acc36ec7e3b717 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 16:26:28 +0200 Subject: [PATCH 27/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] cd: blocked tests --- builtins/cd/cd_vuln_hunt_test.go | 118 +++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 builtins/cd/cd_vuln_hunt_test.go diff --git a/builtins/cd/cd_vuln_hunt_test.go b/builtins/cd/cd_vuln_hunt_test.go new file mode 100644 index 00000000..ace60ced --- /dev/null +++ b/builtins/cd/cd_vuln_hunt_test.go @@ -0,0 +1,118 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: cd) + +package cd_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntBuiltinFlagDriven_CdRejectsFlagValuesAndExpansion(t *testing.T) { + dir := canonicalTempDir(t) + require.NoError(t, os.Mkdir(filepath.Join(dir, "child"), 0o755)) + + for _, script := range []string{ + "cd --physical=true", + "cd --physical=true --help", + "cd --logical=false", + "cd child -P", + "bad=-x; cd $bad", + "cd -@", + } { + stdout, stderr, code := cdRun(t, script, dir) + assert.Equal(t, 1, code, "script %q", script) + assert.Empty(t, stdout, "script %q", script) + assert.Contains(t, stderr, "cd:", "script %q", script) + } +} + +func TestVulnHuntBuiltinFileAccess_CdHomeOldpwdTargetsStaySandboxed(t *testing.T) { + root := canonicalTempDir(t) + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + + script := strings.Join([]string{ + "HOME=" + cdVulnQuote(outside) + "; cd", + "echo home=$?", + "OLDPWD=" + cdVulnQuote(outside) + "; cd -", + "echo dash=$?", + "pwd", + }, "\n") + "\n" + stdout, stderr, code := cdRun(t, script, allowed) + + assert.Equal(t, 0, code) + assert.Equal(t, "home=1\ndash=1\n"+allowed+"\n", stdout) + assert.Contains(t, stderr, "permission denied") +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_FailedCdDoesNotCorruptRelativeReads(t *testing.T) { + dir := canonicalTempDir(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "safe.txt"), []byte("safe\n"), 0o644)) + + stdout, stderr, code := cdRun(t, "cd missing 2>/dev/null\ncat safe.txt\npwd\n", dir) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "safe\n"+dir+"\n", stdout) +} + +func TestVulnHuntBuiltinSubshellIsolation_CdInChildScopes(t *testing.T) { + dir := canonicalTempDir(t) + child := filepath.Join(dir, "child") + require.NoError(t, os.Mkdir(child, 0o755)) + + script := "( cd child; pwd )\nprintf 'x\\n' | { cd child; pwd; }\npwd\n" + stdout, stderr, code := cdRun(t, script, dir) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, child+"\n"+child+"\n"+dir+"\n", stdout) +} + +func TestVulnHuntBuiltinRedirectionChain_CdRedirsRestoreAndDoNotWrite(t *testing.T) { + dir := canonicalTempDir(t) + child := filepath.Join(dir, "child") + require.NoError(t, os.Mkdir(child, 0o755)) + before, err := os.ReadDir(dir) + require.NoError(t, err) + + script := "cd child < no-such-input 2>/dev/null\necho status=$?\npwd\ncd child >/dev/null\npwd\n" + stdout, stderr, code := cdRun(t, script, dir) + after, err := os.ReadDir(dir) + require.NoError(t, err) + + assert.Equal(t, 0, code) + assert.Contains(t, stderr, "no-such-input") + assert.Equal(t, "status=1\n"+dir+"\n"+child+"\n", stdout) + assert.Len(t, after, len(before), "cd with redirections must not create filesystem entries") +} + +func TestVulnHuntBuiltinSignalContext_CdLoopRespectsCancellation(t *testing.T) { + dir := canonicalTempDir(t) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + start := time.Now() + + _, _, _ = cdRunCtx(ctx, t, "while true; do cd .; done\n", dir) + + assert.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + assert.Less(t, time.Since(start), 5*time.Second) +} + +func cdVulnQuote(s string) string { + return "'" + strings.ReplaceAll(filepath.ToSlash(s), "'", `'\''`) + "'" +} From 266c50febc0b1fb1aa928dd689b10b0af01cdcf2 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 16:33:01 +0200 Subject: [PATCH 28/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] allowedpaths: blocked tests --- allowedpaths/sandbox_vuln_hunt_test.go | 189 +++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 allowedpaths/sandbox_vuln_hunt_test.go diff --git a/allowedpaths/sandbox_vuln_hunt_test.go b/allowedpaths/sandbox_vuln_hunt_test.go new file mode 100644 index 00000000..c30f00c8 --- /dev/null +++ b/allowedpaths/sandbox_vuln_hunt_test.go @@ -0,0 +1,189 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: allowedpaths-sandbox) + +package allowedpaths + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntSubsystemAllowedPathsSandbox_OpenReadOnlyAndTraversalBlocked(t *testing.T) { + parent := t.TempDir() + allowed := filepath.Join(parent, "allowed") + outside := filepath.Join(parent, "outside") + siblingPrefix := filepath.Join(parent, "allowed-sibling") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.Mkdir(siblingPrefix, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "safe.txt"), []byte("safe"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(siblingPrefix, "secret.txt"), []byte("sibling"), 0o644)) + + sb, _, err := New([]string{allowed}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.Open("safe.txt", allowed, os.O_RDONLY, 0) + require.NoError(t, err) + data, err := io.ReadAll(f) + require.NoError(t, err) + require.NoError(t, f.Close()) + assert.Equal(t, "safe", string(data)) + + for _, flag := range []int{os.O_WRONLY, os.O_RDWR, os.O_CREATE, os.O_TRUNC, os.O_APPEND, os.O_WRONLY | os.O_CREATE} { + f, err := sb.Open("safe.txt", allowed, flag, 0o644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission, "flag %d", flag) + } + + for _, path := range []string{ + filepath.Join(outside, "secret.txt"), + filepath.Join("..", "outside", "secret.txt"), + filepath.Join(siblingPrefix, "secret.txt"), + } { + f, err := sb.Open(path, allowed, os.O_RDONLY, 0) + assert.Nil(t, f, "path %q", path) + assert.ErrorIs(t, err, os.ErrPermission, "path %q", path) + } +} + +func TestVulnHuntSubsystemAllowedPathsSandbox_CrossRootSymlinkTerminalSemantics(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir1, "sub"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir1, "sub", "file.txt"), []byte("data"), 0o644)) + require.NoError(t, os.Symlink("file.txt", filepath.Join(dir1, "sub", "leaf.lnk"))) + require.NoError(t, os.Symlink(filepath.Join(dir1, "sub"), filepath.Join(dir2, "bridge"))) + require.NoError(t, os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(dir2, "escape.lnk"))) + + sb, _, err := New([]string{dir1, dir2}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.Open(filepath.Join("bridge", "file.txt"), dir2, os.O_RDONLY, 0) + require.NoError(t, err) + data, err := io.ReadAll(f) + require.NoError(t, err) + require.NoError(t, f.Close()) + assert.Equal(t, "data", string(data)) + + info, err := sb.Lstat(filepath.Join("bridge", "leaf.lnk"), dir2) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&fs.ModeSymlink) + target, err := sb.Readlink(filepath.Join("bridge", "leaf.lnk"), dir2) + require.NoError(t, err) + assert.Equal(t, "file.txt", target) + + f, err = sb.Open("escape.lnk", dir2, os.O_RDONLY, 0) + assert.Nil(t, f) + assert.Error(t, err) +} + +func TestVulnHuntSubsystemAllowedPathsSandbox_ReadDirCapsAndLimitedBounds(t *testing.T) { + dir := t.TempDir() + for i := range 5 { + require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%02d", i)), nil, 0o644)) + } + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + entries, err := sb.readDirN(".", dir, 3) + assert.Nil(t, entries) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too many entries") + + entries, truncated, err := sb.ReadDirLimited(".", dir, -100, 2) + require.NoError(t, err) + assert.True(t, truncated) + assert.Len(t, entries, 2) + + entries, truncated, err = sb.ReadDirLimited(".", dir, 100, 2) + require.NoError(t, err) + assert.False(t, truncated) + assert.Empty(t, entries) +} + +func TestVulnHuntSubsystemAllowedPathsSandbox_HostPrefixAndNullDeviceBoundaries(t *testing.T) { + hostPrefix, pods, containers := setupContainerDirsForVulnHunt(t) + outside := filepath.Join(hostPrefix, "etc", "secret") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "shadow"), []byte("secret"), 0o644)) + require.NoError(t, os.Symlink("/etc/secret/shadow", filepath.Join(containers, "escape.log"))) + + sb, _, err := New([]string{pods, containers}) + require.NoError(t, err) + defer sb.Close() + sb.SetHostPrefix(hostPrefix) + + f, err := sb.Open("app.log", containers, os.O_RDONLY, 0) + require.NoError(t, err) + require.NoError(t, f.Close()) + f, err = sb.Open("escape.log", containers, os.O_RDONLY, 0) + assert.Nil(t, f) + assert.Error(t, err) + + info, err := sb.Stat(os.DevNull, containers) + require.NoError(t, err) + assert.False(t, info.IsDir()) + f, err = sb.Open(os.DevNull, containers, os.O_RDONLY, 0) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) +} + +func TestVulnHuntSubsystemAllowedPathsSandbox_NilAndEmptySandboxesFailClosed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("data"), 0o644)) + + var nilSB *Sandbox + assert.Nil(t, nilSB.Paths()) + assert.NoError(t, nilSB.Close()) + _, err := nilSB.Open("file.txt", dir, os.O_RDONLY, 0) + assert.ErrorIs(t, err, os.ErrPermission) + _, err = nilSB.Stat("file.txt", dir) + assert.ErrorIs(t, err, os.ErrPermission) + + emptySB, _, err := New(nil) + require.NoError(t, err) + defer emptySB.Close() + _, err = emptySB.Open("file.txt", dir, os.O_RDONLY, 0) + assert.ErrorIs(t, err, os.ErrPermission) + _, err = emptySB.ReadDir(".", dir) + assert.ErrorIs(t, err, os.ErrPermission) +} + +func TestVulnHuntSubsystemAllowedPathsSandbox_PortableErrorsKeepOperationAndPath(t *testing.T) { + err := PortablePathError(&os.PathError{Op: "openat", Path: "x", Err: fs.ErrPermission}) + var pe *os.PathError + require.True(t, errors.As(err, &pe)) + assert.Equal(t, "openat", pe.Op) + assert.Equal(t, "x", pe.Path) + assert.Equal(t, "permission denied", pe.Err.Error()) +} + +func setupContainerDirsForVulnHunt(t *testing.T) (hostPrefix, pods, containers string) { + t.Helper() + root := t.TempDir() + hostPrefix = root + pods = filepath.Join(root, "var", "log", "pods") + containers = filepath.Join(root, "var", "log", "containers") + require.NoError(t, os.MkdirAll(pods, 0o755)) + require.NoError(t, os.MkdirAll(containers, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pods, "app.log"), []byte("log line"), 0o644)) + require.NoError(t, os.Symlink("/var/log/pods/app.log", filepath.Join(containers, "app.log"))) + return hostPrefix, pods, containers +} From 2982a1501633307f8d1f99ead2486830656672a9 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 16:46:42 +0200 Subject: [PATCH 29/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] ss: blocked tests --- builtins/ss/ss_vuln_hunt_internal_test.go | 48 ++++++++ builtins/ss/ss_vuln_hunt_test.go | 136 ++++++++++++++++++++++ builtins/ss/ss_vuln_hunt_unix_test.go | 38 ++++++ 3 files changed, 222 insertions(+) create mode 100644 builtins/ss/ss_vuln_hunt_internal_test.go create mode 100644 builtins/ss/ss_vuln_hunt_test.go create mode 100644 builtins/ss/ss_vuln_hunt_unix_test.go diff --git a/builtins/ss/ss_vuln_hunt_internal_test.go b/builtins/ss/ss_vuln_hunt_internal_test.go new file mode 100644 index 00000000..ff96dd52 --- /dev/null +++ b/builtins/ss/ss_vuln_hunt_internal_test.go @@ -0,0 +1,48 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Vulnerability-hunt regression tests for campaign 2026-05-20-gpt-5.5-cyber-3. + +package ss + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVulnHuntBuiltinDeclaredVsImplemented_FilterPrecedenceMatrix(t *testing.T) { + allProtocols := options{showTCP: true, showUDP: true, showUnix: true} + + showAllAndListenOnly := allProtocols + showAllAndListenOnly.showAll = true + showAllAndListenOnly.listenOnly = true + assert.True(t, filterEntry(showAllAndListenOnly, socketEntry{kind: sockTCP4, state: "ESTAB"})) + assert.True(t, filterEntry(showAllAndListenOnly, socketEntry{kind: sockTCP4, state: "LISTEN"})) + assert.True(t, filterEntry(showAllAndListenOnly, socketEntry{kind: sockUnix, state: "UNKNOWN"})) + + ipv4Only := allProtocols + ipv4Only.showAll = true + ipv4Only.ipv4Only = true + assert.True(t, filterEntry(ipv4Only, socketEntry{kind: sockTCP4, state: "ESTAB"})) + assert.False(t, filterEntry(ipv4Only, socketEntry{kind: sockTCP6, state: "ESTAB"})) + assert.True(t, filterEntry(ipv4Only, socketEntry{kind: sockUnix, state: "LISTEN"}), "IP filters must not drop Unix sockets") + + ipv6Only := allProtocols + ipv6Only.showAll = true + ipv6Only.ipv6Only = true + assert.False(t, filterEntry(ipv6Only, socketEntry{kind: sockUDP4, state: "UNCONN"})) + assert.True(t, filterEntry(ipv6Only, socketEntry{kind: sockUDP6, state: "UNCONN"})) + assert.True(t, filterEntry(ipv6Only, socketEntry{kind: sockUnix, state: "LISTEN"}), "IP filters must not drop Unix sockets") + + bothFamilies := allProtocols + bothFamilies.showAll = true + bothFamilies.ipv4Only = true + bothFamilies.ipv6Only = true + assert.True(t, filterEntry(bothFamilies, socketEntry{kind: sockTCP4, state: "ESTAB"})) + assert.True(t, filterEntry(bothFamilies, socketEntry{kind: sockTCP6, state: "ESTAB"})) + assert.True(t, filterEntry(bothFamilies, socketEntry{kind: sockUDP4, state: "UNCONN"})) + assert.True(t, filterEntry(bothFamilies, socketEntry{kind: sockUDP6, state: "UNCONN"})) +} diff --git a/builtins/ss/ss_vuln_hunt_test.go b/builtins/ss/ss_vuln_hunt_test.go new file mode 100644 index 00000000..aad617be --- /dev/null +++ b/builtins/ss/ss_vuln_hunt_test.go @@ -0,0 +1,136 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Vulnerability-hunt regression tests for campaign 2026-05-20-gpt-5.5-cyber-3. + +package ss_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinFlagDrivenExploit_DangerousFlagsBeforeHelpRejected(t *testing.T) { + tests := []string{ + "ss -F /etc/passwd --help", + "ss --filter=/etc/passwd -h", + "ss --processes --help", + "ss -K -h", + } + for _, script := range tests { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, script) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "ss:") + }) + } +} + +func TestVulnHuntBuiltinFlagDrivenExploit_RejectedAliasMatrix(t *testing.T) { + tests := []string{ + "ss --processes", + "ss --kill", + "ss --events", + "ss --net ns0", + "ss --bpf", + "ss --resolve", + "ss -m", + "ss -z", + "ss -d", + "ss -w", + "ss -S", + "ss -0", + } + for _, script := range tests { + t.Run(script, func(t *testing.T) { + _, stderr, code := cmdRun(t, script) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ss:") + }) + } +} + +func TestVulnHuntBuiltinGTFObinsCoverage_FilterFormsRejectWithoutLeakingMarker(t *testing.T) { + dir := t.TempDir() + secret := filepath.Join(dir, "secret.txt") + const marker = "VULN_HUNT_SS_FILTER_MARKER" + require.NoError(t, os.WriteFile(secret, []byte(marker), 0o600)) + + directScripts := []string{ + fmt.Sprintf("ss --filter=%q", secret), + fmt.Sprintf("ss -F %q", secret), + } + for _, script := range directScripts { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, script) + assert.Equal(t, 1, code) + assert.NotContains(t, stdout, marker) + assert.NotContains(t, stderr, marker) + assert.Contains(t, stderr, "ss:") + }) + } + + stdout, stderr, code := cmdRun(t, fmt.Sprintf(`flag=--filter; ss $flag %q; echo status:$?`, secret)) + require.Equal(t, 0, code) + assert.Contains(t, stdout, "status:1") + assert.NotContains(t, stdout, marker) + assert.NotContains(t, stderr, marker) + assert.Contains(t, stderr, "ss:") +} + +func TestVulnHuntBuiltinFileAccessBypass_PositionalsIgnoredUnderRestrictedAllowedPaths(t *testing.T) { + outsideDir := t.TempDir() + allowedDir := t.TempDir() + secret := filepath.Join(outsideDir, "outside.txt") + const marker = "VULN_HUNT_SS_POSITIONAL_MARKER" + require.NoError(t, os.WriteFile(secret, []byte(marker), 0o600)) + + stdout, stderr, code := runScript( + t, + fmt.Sprintf(`ss -- %q; echo status:$?`, secret), + "", + interp.AllowedPaths([]string{allowedDir}), + ) + require.Equal(t, 0, code) + assert.Contains(t, stdout, "status:") + assert.NotContains(t, stdout, marker) + assert.NotContains(t, stderr, marker) +} + +func TestVulnHuntBuiltinSpecialFiles_PositionalsDoNotReadDevZeroOrDirectories(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, stderr, code := testutil.RunScriptCtx(ctx, t, fmt.Sprintf(`ss -- /dev/zero %q; echo status:$?`, dir), "") + require.Equal(t, 0, code) + assert.Contains(t, stdout, "status:") + assert.NotContains(t, stdout, strings.Repeat("\x00", 8)) + assert.NotContains(t, stderr, "context deadline exceeded") + assert.NoError(t, ctx.Err()) +} + +func TestVulnHuntBuiltinResourceExhaustion_ComplexLiveInvocationReturnsUnderContext(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, stderr, code := testutil.RunScriptCtx(ctx, t, `ss -xaeoH; echo status:$?`, "") + require.Equal(t, 0, code) + assert.Contains(t, stdout, "status:") + assert.NotContains(t, stderr, "context deadline exceeded") + assert.NoError(t, ctx.Err()) +} diff --git a/builtins/ss/ss_vuln_hunt_unix_test.go b/builtins/ss/ss_vuln_hunt_unix_test.go new file mode 100644 index 00000000..f19f9dad --- /dev/null +++ b/builtins/ss/ss_vuln_hunt_unix_test.go @@ -0,0 +1,38 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +// Vulnerability-hunt regression tests for campaign 2026-05-20-gpt-5.5-cyber-3. + +package ss_test + +import ( + "context" + "fmt" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" +) + +func TestVulnHuntBuiltinSpecialFiles_PositionalsDoNotBlockOnFIFO(t *testing.T) { + fifo := filepath.Join(t.TempDir(), "fifo") + require.NoError(t, syscall.Mkfifo(fifo, 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stdout, stderr, code := testutil.RunScriptCtx(ctx, t, fmt.Sprintf(`ss -- %q; echo status:$?`, fifo), "") + require.Equal(t, 0, code) + assert.Contains(t, stdout, "status:") + assert.NotContains(t, stderr, "context deadline exceeded") + assert.NoError(t, ctx.Err()) +} From 022651865b3874c49ac7ea7b91832ea6ed769230 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 16:54:12 +0200 Subject: [PATCH 30/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] cmd_separator: blocked tests --- interp/cmd_separator_vuln_hunt_test.go | 211 +++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 interp/cmd_separator_vuln_hunt_test.go diff --git a/interp/cmd_separator_vuln_hunt_test.go b/interp/cmd_separator_vuln_hunt_test.go new file mode 100644 index 00000000..a8c0c1e8 --- /dev/null +++ b/interp/cmd_separator_vuln_hunt_test.go @@ -0,0 +1,211 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: cmd_separator (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" +) + +func runCmdSeparatorVulnHuntScript(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), parseScript(t, script)) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_SeparatorsFromVariablesNotReparsed(t *testing.T) { + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, "PAYLOAD='echo SAFE; echo HACKED'\n$PAYLOAD\necho status=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "SAFE; echo HACKED\nstatus=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureExpansionChain_CmdSubstSeparatorsNotReparsed(t *testing.T) { + script := "PAYLOAD=$(printf 'echo SAFE; echo HACKED')\n$PAYLOAD\necho status=$?\n" + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "SAFE; echo HACKED\nstatus=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_LineEndingsAndComments(t *testing.T) { + tests := map[string]string{ + "crlf": "echo one\r\necho two\r\n", + "semicolon_comment": "echo one; # ignored comment\necho two\n", + "quoted_semicolon": "echo 'one;two'; echo three\n", + } + for name, script := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, script, "") + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + switch name { + case "quoted_semicolon": + assert.Equal(t, "one;two\nthree\n", stdout) + default: + assert.Equal(t, "one\ntwo\n", stdout) + } + }) + } +} + +func TestVulnHuntShellFeatureParserConfusion_UnsupportedBackgroundRejectedBeforeExecution(t *testing.T) { + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, "echo before; echo bg & echo after\n", "") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "background execution") +} + +func TestVulnHuntShellFeatureSubshellIsolation_SeparatorScopes(t *testing.T) { + script := strings.Join([]string{ + "X=parent; (X=child; Y=hidden; echo sub=$X/$Y); echo parent=$X/${Y}", + "{ Z=brace; }; echo brace=$Z", + "Q=$(A=cmdsubst; echo value); echo q=$Q a=${A}", + }, "\n") + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "sub=child/hidden\nparent=parent/\nbrace=brace\nq=value a=\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_ExitStatusAndExitStop(t *testing.T) { + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, "false; echo after_false=$?; definitely_missing; echo after_missing=$?; exit 7; echo unreachable\n", "") + + require.NoError(t, err) + assert.Equal(t, 7, code) + assert.Equal(t, "after_false=1\nafter_missing=127\n", stdout) + assert.Contains(t, stderr, "definitely_missing") + assert.NotContains(t, stdout, "unreachable") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_MaxScriptBytesRejectsBeforeParse(t *testing.T) { + _, err := ParseScript(strings.Repeat(";", MaxScriptBytes+1), "oversized-separator-chain.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") +} + +func TestVulnHuntShellFeatureCompositionAttack_RedirectionFailureDoesNotPoisonNextStatement(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("from-file\n"), 0o644)) + + script := "cat < missing.txt; echo after-missing; cat < input.txt; echo after-cat\n" + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "after-missing\nfrom-file\nafter-cat\n", stdout) + assert.Contains(t, stderr, "missing.txt") +} + +func TestVulnHuntShellFeatureRedirectionChain_RedirectsRestoreAcrossSeparators(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("visible\n"), 0o644)) + + script := "echo hidden > /dev/null; echo stdout-restored; cat < input.txt; echo stdin-restored\n" + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "stdout-restored\nvisible\nstdin-restored\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureReadonlyBypass_SequencedAssignmentsRespectReadonly(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO_VAR", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + + err = r.Run(context.Background(), parseScript(t, "RO_VAR=changed; echo assign_status=$? value=$RO_VAR; RO_VAR=inline echo hidden; echo after=$RO_VAR\n")) + require.NoError(t, err) + assert.Equal(t, "assign_status=1 value=original\nafter=original\n", stdout.String()) + assert.Contains(t, stderr.String(), "readonly") + assert.NotContains(t, stdout.String(), "hidden") +} + +func TestVulnHuntShellFeatureSignalContext_LongSeparatorChainStopsBeforeNextStatement(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), MaxExecutionTime(25*time.Millisecond)) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + r.execHandler = func(ctx context.Context, _ []string) error { + <-ctx.Done() + return ctx.Err() + } + + err = r.Run(context.Background(), parseScript(t, "slow_external; echo should_not_run\n")) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureCompositionAttack_AndOrPipelinesDoNotLeakAcrossSeparators(t *testing.T) { + script := strings.Join([]string{ + "false && echo skipped; echo after-and", + "true || echo skipped; echo after-or", + "echo piped | cat; echo after-pipe", + }, "\n") + stdout, stderr, code, err := runCmdSeparatorVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "after-and\nafter-or\npiped\nafter-pipe\n", stdout) +} From 483ad7baf0d0852d7f15bb978dbe4c5fef6574c0 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 17:05:24 +0200 Subject: [PATCH 31/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] pipe: blocked tests --- interp/pipe_vuln_hunt_test.go | 272 ++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 interp/pipe_vuln_hunt_test.go diff --git a/interp/pipe_vuln_hunt_test.go b/interp/pipe_vuln_hunt_test.go new file mode 100644 index 00000000..5c93b834 --- /dev/null +++ b/interp/pipe_vuln_hunt_test.go @@ -0,0 +1,272 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: pipe (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" +) + +func runPipeVulnHuntScript(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), parseScript(t, script)) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_PipeTokensFromExpansionNotReparsed(t *testing.T) { + stdout, stderr, code, err := runPipeVulnHuntScript(t, "PAYLOAD='echo SAFE | cat'\n$PAYLOAD\necho status=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "SAFE | cat\nstatus=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_PipeAllAndBackgroundRejectedBeforeExecution(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "pipe_all": { + script: "echo before; echo secret |& cat\n", + want: "|& is not supported", + }, + "background_after_pipe": { + script: "echo before; echo left | cat & echo after\n", + want: "background execution", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runPipeVulnHuntScript(t, tc.script, "") + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.want) + }) + } +} + +func TestVulnHuntShellFeatureParserConfusion_LineContinuationsAfterPipe(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "linebreak": { + script: "printf 'alpha\\n' |\ncat\n", + want: "alpha\n", + }, + "blank_line": { + script: "printf 'beta\\n' |\n\ncat\n", + want: "beta\n", + }, + "comment": { + script: "printf 'gamma\\n' | # ignored comment\ncat\n", + want: "gamma\n", + }, + "crlf": { + script: "printf 'delta\\n' |\r\ncat\r\n", + want: "delta\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runPipeVulnHuntScript(t, tc.script, "") + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, tc.want, stdout) + assert.Empty(t, stderr) + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_PipelineStateDoesNotLeak(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "sub") + require.NoError(t, os.Mkdir(subdir, 0o755)) + + script := strings.Join([]string{ + "X=parent", + "{ X=left; echo left=$X; } | cat", + "echo after_left=$X", + "echo value | read X", + "echo after_read=$X", + "{ cd sub; pwd; } | cat", + "pwd", + }, "\n") + stdout, stderr, code, err := runPipeVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, strings.Join([]string{ + "left=left", + "after_left=parent", + "after_read=parent", + subdir, + dir, + "", + }, "\n"), stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_RightmostStatusAndNegation(t *testing.T) { + script := strings.Join([]string{ + "false | true", + "echo s1=$?", + "true | false", + "echo s2=$?", + "false | false | true", + "echo s3=$?", + "! true | false", + "echo s4=$?", + "! false | true", + "echo s5=$?", + }, "\n") + stdout, stderr, code, err := runPipeVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "s1=0\ns2=1\ns3=0\ns4=0\ns5=1\n", stdout) +} + +func TestVulnHuntShellFeatureCompositionAttack_RedirectionPrecedenceAndRestore(t *testing.T) { + script := strings.Join([]string{ + "echo hidden >/dev/null | cat", + "echo after-left", + "echo hidden-right | cat >/dev/null", + "echo after-right", + "echo visible | cat", + }, "\n") + stdout, stderr, code, err := runPipeVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "after-left\nafter-right\nvisible\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureRedirectionChain_FailedStageRedirectDoesNotPoisonNextPipeline(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("from-file\n"), 0o644)) + + script := "cat < missing.txt | cat\necho after-missing\ncat < input.txt | cat\necho after-cat\n" + stdout, stderr, code, err := runPipeVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "after-missing\nfrom-file\nafter-cat\n", stdout) + assert.Contains(t, stderr, "missing.txt") +} + +func TestVulnHuntShellFeatureReadonlyBypass_PipelineStagesRespectReadonly(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO_VAR", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + + err = r.Run(context.Background(), parseScript(t, "echo changed | read RO_VAR\necho after_read=$RO_VAR\nRO_VAR=inline echo hidden | cat\necho after_inline=$RO_VAR\n")) + require.NoError(t, err) + assert.Equal(t, "after_read=original\nafter_inline=original\n", stdout.String()) + assert.Contains(t, stderr.String(), "readonly") + assert.NotContains(t, stdout.String(), "hidden") +} + +func TestVulnHuntShellFeatureSignalContext_EarlyRightExitClosesPipe(t *testing.T) { + dir := t.TempDir() + var data strings.Builder + for range 512 { + data.WriteString("line\n") + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.txt"), []byte(data.String()), 0o644)) + + stdout, stderr, code, err := runPipeVulnHuntScript(t, "cat big.txt | head -n 1\necho after-head\n", dir, AllowedPaths([]string{dir}), MaxExecutionTime(2*time.Second)) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "line\nafter-head\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureSignalContext_LeftStageCancellationUnblocksPipeline(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), MaxExecutionTime(25*time.Millisecond)) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + r.execHandler = func(ctx context.Context, _ []string) error { + <-ctx.Done() + return ctx.Err() + } + + err = r.Run(context.Background(), parseScript(t, "slow_external | true\n")) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureSubshellIsolation_GlobReadDirLimitSharedAcrossPipelineStages(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"one.txt", "two.txt"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(name+"\n"), 0o644)) + } + + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), AllowedPaths([]string{dir})) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + + err = r.Run(context.Background(), parseScript(t, "echo * | cat >/dev/null\necho * | cat >/dev/null\n")) + require.NoError(t, err) + require.NotNil(t, r.globReadDirCount) + assert.Equal(t, int64(2), r.globReadDirCount.Load()) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} From 4592bec30e391d636c589bb50d1afd9c8199cb8b Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 17:15:06 +0200 Subject: [PATCH 32/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] input_processing: blocked tests --- cmd/rshell/input_processing_vuln_hunt_test.go | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 cmd/rshell/input_processing_vuln_hunt_test.go diff --git a/cmd/rshell/input_processing_vuln_hunt_test.go b/cmd/rshell/input_processing_vuln_hunt_test.go new file mode 100644 index 00000000..16f16b07 --- /dev/null +++ b/cmd/rshell/input_processing_vuln_hunt_test.go @@ -0,0 +1,95 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: input_processing (shell-feature) + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntShellFeatureExpansionChain_CommentAndBlankInputStaysNoop(t *testing.T) { + script := "# $(echo hacked)\n# > /tmp/out\n\n \t \n" + code, stdout, stderr := runCLIWithStdin(t, script, "--allow-all-commands") + + assert.Equal(t, 0, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_UnsupportedLaterSyntaxRejectedBeforeExecution(t *testing.T) { + code, stdout, stderr := runCLIWithStdin(t, "echo before\necho bg & echo after\n", "--allow-all-commands") + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "background execution") +} + +func TestVulnHuntShellFeatureParserConfusion_CRLFAndNoTrailingNewline(t *testing.T) { + code, stdout, stderr := runCLIWithStdin(t, "echo one\r\necho two\r\nprintf three", "--allow-all-commands") + + assert.Equal(t, 0, code) + assert.Equal(t, "one\ntwo\nthree", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_EmbeddedNULSourceHandledDeterministically(t *testing.T) { + code, stdout, stderr := runCLIWithStdin(t, "echo before\x00echo after\n", "--allow-all-commands") + + assert.Equal(t, 0, code) + assert.Equal(t, "beforeecho after\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureSubshellIsolation_SourceStdinAndCommandStdinSeparatedByMode(t *testing.T) { + t.Run("stdin_script_gets_empty_command_stdin", func(t *testing.T) { + code, stdout, stderr := runCLIWithStdin(t, "cat\n", "--allow-all-commands") + assert.Equal(t, 0, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + }) + + t.Run("command_string_gets_caller_stdin", func(t *testing.T) { + code, stdout, stderr := runCLIWithStdin(t, "payload\n", "--allow-all-commands", "-c", "cat") + assert.Equal(t, 0, code) + assert.Equal(t, "payload\n", stdout) + assert.Empty(t, stderr) + }) +} + +func TestVulnHuntShellFeatureSubshellIsolation_FileScriptsGetFreshStdinReaders(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.sh") + second := filepath.Join(dir, "second.sh") + assert.NoError(t, os.WriteFile(first, []byte("cat\n"), 0o644)) + assert.NoError(t, os.WriteFile(second, []byte("cat\n"), 0o644)) + + code, stdout, stderr := runCLIWithStdin(t, "payload\n", "--allow-all-commands", first, second) + + assert.Equal(t, 0, code) + assert.Equal(t, "payload\npayload\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_LongSourceLineWithinScriptLimit(t *testing.T) { + longComment := "#" + strings.Repeat("x", 1<<20+1) + "\n" + script := longComment + "echo ok\n" + assert.LessOrEqual(t, len(script), interp.MaxScriptBytes) + + code, stdout, stderr := runCLIWithStdin(t, script, "--allow-all-commands") + + assert.Equal(t, 0, code) + assert.Equal(t, "ok\n", stdout) + assert.Empty(t, stderr) +} From edf47cb7ee0c86bc89d60bf79f994feb34d4926e Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 17:21:31 +0200 Subject: [PATCH 33/73] [vuln-hunt 2026-05-20-gpt-5.5-cyber-3] subshell: blocked tests --- interp/subshell_cyber3_vuln_hunt_test.go | 219 +++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 interp/subshell_cyber3_vuln_hunt_test.go diff --git a/interp/subshell_cyber3_vuln_hunt_test.go b/interp/subshell_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..e3f08295 --- /dev/null +++ b/interp/subshell_cyber3_vuln_hunt_test.go @@ -0,0 +1,219 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: subshell (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" +) + +func runSubshellVulnHuntScript(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), parseScript(t, script)) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_SubshellTokensFromExpansionNotReparsed(t *testing.T) { + stdout, stderr, code, err := runSubshellVulnHuntScript(t, "PAYLOAD='echo SAFE ( echo HACKED )'\n$PAYLOAD\necho status=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "SAFE ( echo HACKED )\nstatus=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_UnsupportedSubshellContentRejectedBeforeExecution(t *testing.T) { + stdout, stderr, code, err := runSubshellVulnHuntScript(t, "echo before\n(echo bg & echo after)\n", "") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "background execution") +} + +func TestVulnHuntShellFeatureSubshellIsolation_StateDoesNotLeakToParent(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "sub") + require.NoError(t, os.Mkdir(subdir, 0o755)) + + script := strings.Join([]string{ + "X=parent", + "(X=child; Y=new; cd sub; echo child=$X/$Y; pwd)", + "echo parent=$X/${Y}", + "pwd", + }, "\n") + stdout, stderr, code, err := runSubshellVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, strings.Join([]string{ + "child=child/new", + subdir, + "parent=parent/", + dir, + "", + }, "\n"), stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_StatusAndNegation(t *testing.T) { + script := strings.Join([]string{ + "(false)", + "echo false_status=$?", + "(exit 7)", + "echo exit_status=$?", + "! (false)", + "echo neg_false=$?", + "! (true)", + "echo neg_true=$?", + }, "\n") + stdout, stderr, code, err := runSubshellVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "false_status=1\nexit_status=7\nneg_false=0\nneg_true=1\n", stdout) +} + +func TestVulnHuntShellFeatureRedirectionChain_SubshellRedirectsDoNotPoisonParent(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("from-file\n"), 0o644)) + + script := "(cat < missing.txt)\necho after-missing\n(cat < input.txt)\necho after-good\ncat < input.txt\n" + stdout, stderr, code, err := runSubshellVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "after-missing\nfrom-file\nafter-good\nfrom-file\n", stdout) + assert.Contains(t, stderr, "missing.txt") +} + +func TestVulnHuntShellFeatureRedirectionChain_SubshellCannotBypassAllowedPaths(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + secret := filepath.Join(root, "secret") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(secret, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("secret\n"), 0o644)) + + stdout, stderr, code, err := runSubshellVulnHuntScript(t, "(cat ../secret/hidden.txt)\necho status=$?\n", allowed, AllowedPaths([]string{allowed})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "secret") +} + +func TestVulnHuntShellFeatureReadonlyBypass_SubshellRespectsReadonly(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO_VAR", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + + err = r.Run(context.Background(), parseScript(t, "(RO_VAR=hacked; echo in=$RO_VAR)\necho out=$RO_VAR\n")) + require.NoError(t, err) + assert.Equal(t, "in=original\nout=original\n", stdout.String()) + assert.Contains(t, stderr.String(), "readonly") + assert.NotContains(t, stdout.String(), "hacked") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_VarStorageCapSharedWithSubshell(t *testing.T) { + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("PAD", expand.Variable{ + Set: true, + Kind: expand.String, + Str: strings.Repeat("x", MaxTotalVarsBytes-2), + })) + + sub := r.subshell(false) + err = sub.writeEnv.Set("EXTRA", expand.Variable{Set: true, Kind: expand.String, Str: "abcd"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "variable storage limit") +} + +func TestVulnHuntShellFeatureSubshellIsolation_GlobReadDirLimitSharedWithSubshell(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"one.txt", "two.txt"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(name+"\n"), 0o644)) + } + + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), AllowedPaths([]string{dir})) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + + err = r.Run(context.Background(), parseScript(t, "(echo * >/dev/null)\necho * >/dev/null\n")) + require.NoError(t, err) + require.NotNil(t, r.globReadDirCount) + assert.Equal(t, int64(2), r.globReadDirCount.Load()) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureSignalContext_SubshellCancellationPropagates(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), MaxExecutionTime(25*time.Millisecond)) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + r.execHandler = func(ctx context.Context, _ []string) error { + <-ctx.Done() + return ctx.Err() + } + + err = r.Run(context.Background(), parseScript(t, "(slow_external)\necho after\n")) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} From 549e79083020139cde43b0689995ac5e6c3a655c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 17:31:15 +0200 Subject: [PATCH 34/73] test: add xargs vuln hunt regressions --- builtins/xargs/xargs_vuln_hunt_test.go | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/builtins/xargs/xargs_vuln_hunt_test.go b/builtins/xargs/xargs_vuln_hunt_test.go index 7a089598..5131051b 100644 --- a/builtins/xargs/xargs_vuln_hunt_test.go +++ b/builtins/xargs/xargs_vuln_hunt_test.go @@ -13,6 +13,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -102,3 +103,48 @@ func TestVulnHuntBuiltinIntegerOverflow_LRangeEdges(t *testing.T) { }) } } + +func TestVulnHuntBuiltinFlagDrivenExploit_DangerousGNUOptionsRejected(t *testing.T) { + dir := t.TempDir() + for _, script := range []string{ + "echo a | xargs -p echo", + "echo a | xargs -P 2 echo", + "echo a | xargs --process-slot-var=SLOT echo", + "echo a | xargs --open-tty echo", + "echo a | xargs --show-limits echo", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, script, dir) + assert.NotEqual(t, 0, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "xargs:") + }) + } +} + +func TestVulnHuntBuiltinFileAccessBypass_ArgFileSymlinkOutsideSandbox(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks are restricted on Windows") + } + allowed := t.TempDir() + forbidden := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("S3CR3T\n"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(forbidden, "secret.txt"), filepath.Join(allowed, "link"))) + + stdout, stderr, code := runScript(t, "xargs -a link echo", allowed, + interp.AllowedPaths([]string{allowed})) + assert.NotEqual(t, 0, code) + assert.NotContains(t, stdout, "S3CR3T") + assert.Contains(t, stderr, "xargs:") +} + +func TestVulnHuntBuiltinCompositionAttack_ChildReadCannotMutateParentVariable(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "items.txt"), nil, 0o644)) + + stdout, stderr, code := cmdRun(t, + "printf 'payload\\n' | xargs -a items.txt read X; echo \"X=[$X]\"", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "X=[]\n", stdout) + assert.Contains(t, stderr, "read: variable access is not available") +} From d6e6ff9568f0d0420fe49d8340b66919bfc27b18 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 17:40:06 +0200 Subject: [PATCH 35/73] test: add heredoc vuln hunt regressions --- interp/tests/heredoc_vuln_hunt_test.go | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 interp/tests/heredoc_vuln_hunt_test.go diff --git a/interp/tests/heredoc_vuln_hunt_test.go b/interp/tests/heredoc_vuln_hunt_test.go new file mode 100644 index 00000000..c91ce56d --- /dev/null +++ b/interp/tests/heredoc_vuln_hunt_test.go @@ -0,0 +1,79 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: heredoc (shell-feature) + +package tests_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntShellFeatureFileAccessBypass_HeredocCatShortcutSandboxed(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + forbidden := filepath.Join(base, "forbidden") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(forbidden, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("S3CR3T\n"), 0o644)) + + script := "cat < Date: Wed, 20 May 2026 17:47:19 +0200 Subject: [PATCH 36/73] test: add inline var vuln hunt regressions --- interp/tests/inline_var_vuln_hunt_test.go | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 interp/tests/inline_var_vuln_hunt_test.go diff --git a/interp/tests/inline_var_vuln_hunt_test.go b/interp/tests/inline_var_vuln_hunt_test.go new file mode 100644 index 00000000..73316699 --- /dev/null +++ b/interp/tests/inline_var_vuln_hunt_test.go @@ -0,0 +1,56 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: inline_var (shell-feature) + +package tests_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntShellFeatureFileAccessBypass_InlineCatShortcutSandboxed(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + forbidden := filepath.Join(base, "forbidden") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(forbidden, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("S3CR3T\n"), 0o644)) + + script := "A=before; A=$(<../forbidden/secret.txt) echo HIT; echo after=[$A]\n" + stdout, stderr, code := redirRunWithOpts(t, script, allowed, + interp.AllowedPaths([]string{allowed}), + interp.AllowedCommands([]string{"rshell:cat", "rshell:echo"})) + + assert.Equal(t, 0, code) + assert.Equal(t, "HIT\nafter=[before]\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "S3CR3T") +} + +func TestVulnHuntShellFeatureCompositionAttack_InlineCdPwdSpoofCannotEscapeSandbox(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + sub := filepath.Join(allowed, "sub") + require.NoError(t, os.MkdirAll(sub, 0o755)) + + script := "PWD=/spoof cd sub\npwd\necho PWD=$PWD\ncd -\npwd\necho PWD=$PWD\n" + stdout, stderr, code := redirRunWithOpts(t, script, allowed, + interp.AllowedPaths([]string{allowed}), + interpoption.AllowAllCommands().(interp.RunnerOption)) + + assert.Equal(t, 0, code) + assert.Equal(t, sub+"\nPWD="+sub+"\n"+sub+"\nPWD="+sub+"\n", stdout) + assert.Contains(t, stderr, "cd: /spoof: permission denied") +} From 59d942a2639e807838d434eb64b9f2830925e68f Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 18:00:39 +0200 Subject: [PATCH 37/73] test: add ip vuln hunt regressions --- builtins/ip/ip_vuln_hunt_test.go | 28 +++++ builtins/tests/ip/ip_vuln_hunt_linux_test.go | 57 ++++++++++ builtins/tests/ip/ip_vuln_hunt_test.go | 112 +++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 builtins/ip/ip_vuln_hunt_test.go create mode 100644 builtins/tests/ip/ip_vuln_hunt_linux_test.go create mode 100644 builtins/tests/ip/ip_vuln_hunt_test.go diff --git a/builtins/ip/ip_vuln_hunt_test.go b/builtins/ip/ip_vuln_hunt_test.go new file mode 100644 index 00000000..a2c59bdc --- /dev/null +++ b/builtins/ip/ip_vuln_hunt_test.go @@ -0,0 +1,28 @@ +package ip + +import ( + "go/parser" + "go/token" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVulnHuntBuiltinIP_StaticImportSurface(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "ip.go", nil, parser.ImportsOnly) + require.NoError(t, err) + + imports := map[string]bool{} + for _, spec := range f.Imports { + path, err := strconv.Unquote(spec.Path.Value) + require.NoError(t, err) + imports[path] = true + } + + require.False(t, imports["os"], "ip.go must not open script-controlled files directly") + require.False(t, imports["io/ioutil"], "ip.go must not use legacy direct file reads") + require.False(t, imports["github.com/DataDog/rshell/allowedpaths"], "ip must not derive route reads from AllowedPaths") + require.True(t, imports["github.com/DataDog/rshell/builtins/internal/procnetroute"], "route reads must stay behind the audited procnetroute helper") +} diff --git a/builtins/tests/ip/ip_vuln_hunt_linux_test.go b/builtins/tests/ip/ip_vuln_hunt_linux_test.go new file mode 100644 index 00000000..bcc9545b --- /dev/null +++ b/builtins/tests/ip/ip_vuln_hunt_linux_test.go @@ -0,0 +1,57 @@ +//go:build linux + +package ip_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/internal/procnetroute" + ipcmd "github.com/DataDog/rshell/builtins/ip" +) + +func TestVulnHuntBuiltinIP_RouteProcPathTraversalRejected(t *testing.T) { + procNetRouteMu.Lock() + orig := ipcmd.ProcNetRoutePath + ipcmd.ProcNetRoutePath = "/proc/../tmp" + t.Cleanup(func() { + ipcmd.ProcNetRoutePath = orig + procNetRouteMu.Unlock() + }) + + stdout, stderr, code := cmdRun(t, "ip route show") + assert.Equal(t, 1, code, "stdout=%q stderr=%q", stdout, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "unsafe procPath") +} + +func TestVulnHuntBuiltinIP_RouteReaderHardFailsOnRouteCap(t *testing.T) { + var b strings.Builder + b.WriteString("Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n") + for range procnetroute.MaxRoutes + 1 { + b.WriteString("eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n") + } + writeProcNetRoute(t, b.String()) + + stdout, stderr, code := cmdRun(t, "ip route show") + require.Equal(t, 1, code, "stdout=%q stderr=%q", stdout, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "exceeded MaxRoutes") +} + +func TestVulnHuntBuiltinIP_RouteReaderHardFailsOnTotalLineCap(t *testing.T) { + var b strings.Builder + b.WriteString("Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n") + for range procnetroute.MaxTotalLines + 1 { + b.WriteString("malformed\n") + } + writeProcNetRoute(t, b.String()) + + stdout, stderr, code := cmdRun(t, "ip route show") + require.Equal(t, 1, code, "stdout=%q stderr=%q", stdout, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "exceeded MaxTotalLines") +} diff --git a/builtins/tests/ip/ip_vuln_hunt_test.go b/builtins/tests/ip/ip_vuln_hunt_test.go new file mode 100644 index 00000000..fb04df61 --- /dev/null +++ b/builtins/tests/ip/ip_vuln_hunt_test.go @@ -0,0 +1,112 @@ +package ip_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntBuiltinIP_DangerousFlagsFailClosed(t *testing.T) { + cases := []string{ + "ip -b /tmp/cmds addr show", + "ip -B addr show", + "ip -batch /tmp/cmds addr show", + "ip --batch /tmp/cmds addr show", + "ip --force addr show", + "ip -force addr show", + "ip -n ns addr show", + "ip --netns ns addr show", + "ip -br addr show", + "ip -h -n ns addr show", + "ip netns exec ns sh", + "ip -- netns exec ns sh", + } + + for _, script := range cases { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, script) + assert.Equal(t, 1, code, "script=%q stdout=%q stderr=%q", script, stdout, stderr) + assert.Empty(t, stdout) + assert.NotEmpty(t, stderr) + }) + } +} + +func TestVulnHuntBuiltinIP_WriteVerbsFailClosed(t *testing.T) { + cases := []struct { + script string + want string + code int + }{ + {`ip addr add 10.0.0.1/24 dev lo`, "write operations", 1}, + {`ip address append 10.0.0.1/24 dev lo`, "write operations", 1}, + {`ip addr show add 10.0.0.1/24`, "unknown token", 1}, + {`ip link set lo up`, "write operations", 1}, + {`ip link show set lo up`, "unknown token", 1}, + {`ip route add default via 1.1.1.1`, "write operations", 1}, + {`ip route -- add default via 1.1.1.1`, "write operations", 1}, + {`ip route save /tmp/routes`, "write operations", 1}, + {`ip route help`, "is unknown", 255}, + } + + for _, tc := range cases { + t.Run(tc.script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, tc.script) + assert.Equal(t, tc.code, code, "stdout=%q stderr=%q", stdout, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.want) + }) + } +} + +func TestVulnHuntBuiltinIP_DiagnosticsQuoteControlChars(t *testing.T) { + cases := []struct { + script string + wantEscaped string + code int + }{ + {"ip \"bad\nobject\"", `bad\nobject`, 1}, + {"ip addr show dev \"lo\nFORGED\"", `lo\nFORGED`, 1}, + {"ip addr show \"token\nFORGED\"", `token\nFORGED`, 1}, + {"ip route \"show\nFORGED\"", `show\nFORGED`, 255}, + {"ip route get \"1.2.3.4\nFORGED\"", `1.2.3.4\nFORGED`, 1}, + } + + for _, tc := range cases { + t.Run(strings.ReplaceAll(tc.wantEscaped, `\n`, "_"), func(t *testing.T) { + stdout, stderr, code := cmdRun(t, tc.script) + require.Equal(t, tc.code, code, "stdout=%q stderr=%q", stdout, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.wantEscaped) + raw := strings.ReplaceAll(tc.wantEscaped, `\n`, "\n") + assert.NotContains(t, strings.TrimSuffix(stderr, "\n"), raw, "diagnostic should quote embedded newlines") + }) + } +} + +func TestVulnHuntBuiltinIP_RouteArgumentsFailBeforeProcRead(t *testing.T) { + cases := []struct { + script string + want string + }{ + {`ip route get 001.2.3.4`, "invalid address"}, + {`ip route get 256.0.0.1`, "invalid address"}, + {`ip route get 1.2.3.4.5`, "invalid address"}, + {`ip route get ::1`, "invalid address"}, + {`ip route get 1.2.3.4 extra`, "unsupported argument"}, + {`ip -6 route show`, "IPv6 routing not supported"}, + {`ip -o route show`, "not supported for route output"}, + {`ip --brief route show`, "not supported for route output"}, + } + + for _, tc := range cases { + t.Run(tc.script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, tc.script) + assert.Equal(t, 1, code, "stdout=%q stderr=%q", stdout, stderr) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.want) + }) + } +} From 750a72adb5faa61ce6e5a0ce466923a928b8ce0d Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 18:11:16 +0200 Subject: [PATCH 38/73] test: add line continuation vuln hunt regressions --- interp/line_continuation_vuln_hunt_test.go | 198 +++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 interp/line_continuation_vuln_hunt_test.go diff --git a/interp/line_continuation_vuln_hunt_test.go b/interp/line_continuation_vuln_hunt_test.go new file mode 100644 index 00000000..d6dd8719 --- /dev/null +++ b/interp/line_continuation_vuln_hunt_test.go @@ -0,0 +1,198 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: line_continuation (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runLineContinuationVulnHuntScript(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "line_continuation_vuln_hunt.sh") + if err != nil { + return "", err.Error() + "\n", 2, nil + } + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = r.Run(ctx, prog) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureParserConfusion_ContinuationsCannotSmuggleUnsupportedSyntax(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "background": { + script: "echo before; echo hidden \\\n& echo after\n", + want: "background execution", + }, + "pipe_all": { + script: "echo before; echo left |\\\n& cat\n", + want: "`|` must be followed by a statement", + }, + "herestring": { + script: "echo before; cat <\\\n<\\\n< data\n", + want: "`<` must be followed by a word", + }, + "process_substitution": { + script: "echo before; cat <\\\n(echo secret)\n", + want: "`<` must be followed by a word", + }, + "function_declaration": { + script: "echo before; f\\\noo() { echo bad; }\necho after\n", + want: "function declarations are not supported", + }, + "readonly_declaration": { + script: "echo before; read\\\nonly X=1\necho after\n", + want: "readonly is not supported", + }, + "file_write_redirect": { + script: "echo before; echo data > ou\\\nt.txt\necho after\n", + want: "> file redirection is not supported", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runLineContinuationVulnHuntScript(t, tc.script) + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout, "whole-file validation must reject before earlier statements execute") + assert.Contains(t, stderr, tc.want) + }) + } +} + +func TestVulnHuntShellFeatureExpansionChain_ExpansionBackslashNewlineNotReparsed(t *testing.T) { + script := "PAYLOAD='echo he\\\nllo | cat'\n$PAYLOAD\necho done\n" + stdout, stderr, code, err := runLineContinuationVulnHuntScript(t, script) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "he\\ llo | cat\ndone\n", stdout) +} + +func TestVulnHuntShellFeatureRedirectionChain_ContinuationPreservesRedirectPolicy(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("from-file\n"), 0o644)) + target := filepath.ToSlash(filepath.Join(dir, "da\\\nta.txt")) + + stdout, stderr, code, err := runLineContinuationVulnHuntScript(t, + "cat < "+target+"\necho hidden > /dev/nu\\\nll\necho visible\n", + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "from-file\nvisible\n", stdout) +} + +func TestVulnHuntShellFeatureBlockedCommand_ContinuationCommandNameCheckedAfterFolding(t *testing.T) { + prog, err := ParseScript("ec\\\nho ok\n", "line_continuation_command_policy.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), AllowedCommands([]string{"rshell:cat"})) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(127), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "command not allowed") +} + +func TestVulnHuntShellFeatureHeredocChain_QuotedAndUnquotedContinuationRules(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "unquoted_consumes": { + script: "cat < Date: Wed, 20 May 2026 18:21:45 +0200 Subject: [PATCH 39/73] test: add tail vuln hunt regressions --- builtins/tail/tail_vuln_hunt_test.go | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 builtins/tail/tail_vuln_hunt_test.go diff --git a/builtins/tail/tail_vuln_hunt_test.go b/builtins/tail/tail_vuln_hunt_test.go new file mode 100644 index 00000000..7e015595 --- /dev/null +++ b/builtins/tail/tail_vuln_hunt_test.go @@ -0,0 +1,120 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: tail (builtin) + +package tail_test + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + tail "github.com/DataDog/rshell/builtins/tail" + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +type repeatByteReader byte + +func (r repeatByteReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte(r) + } + return len(p), nil +} + +func TestVulnHuntBuiltinFlagDrivenExploit_DangerousFollowAliasesRejected(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0o644)) + + for _, script := range []string{ + "tail -F f.txt", + "tail --follow f.txt", + "tail --follow=name f.txt", + "tail --pid=1 f.txt", + "tail --retry f.txt", + `flag="--pid=1"; tail $flag f.txt`, + } { + t.Run(script, func(t *testing.T) { + _, stderr, code := tailRun(t, script, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "tail:") + }) + } +} + +func TestVulnHuntBuiltinFlagDrivenExploit_DoubleDashProtectsBareNumberFilenames(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "-5"), []byte("flag-shaped\n"), 0o644)) + + stdout, stderr, code := tailRun(t, "tail -- -5", dir) + + assert.Equal(t, 0, code) + assert.Equal(t, "flag-shaped\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_InvalidCountsReportBeforeHelp(t *testing.T) { + dir := t.TempDir() + + _, stderr, code := tailRun(t, "tail -n nope --help", dir) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "tail: invalid number of lines: 'nope'") +} + +func TestVulnHuntBuiltinResourceExhaustion_ByteBufferLimitFailsClosed(t *testing.T) { + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, "big.bin")) + require.NoError(t, err) + _, err = io.CopyN(f, repeatByteReader('A'), int64(tail.MaxBytesBuffer)+1) + require.NoError(t, err) + require.NoError(t, f.Close()) + + _, stderr, code := tailRun(t, fmt.Sprintf("tail -c %d big.bin", tail.MaxBytesBuffer+1), dir) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "byte buffer limit exceeded") +} + +func TestVulnHuntBuiltinSpecialFiles_ByteOffsetDevZeroHonorsConfiguredTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader("tail -c +1 /dev/zero"), "") + require.NoError(t, err) + + var stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(nil, io.Discard, &stderr), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.AllowedPaths([]string{"/dev"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + start := time.Now() + err = runner.Run(context.Background(), prog) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 2*time.Second) +} From 917b6a24572e8fa92ada1620c5e321f9bfff7459 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 18:29:20 +0200 Subject: [PATCH 40/73] test: add errors vuln hunt regressions --- interp/errors_vuln_hunt_test.go | 130 ++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 interp/errors_vuln_hunt_test.go diff --git a/interp/errors_vuln_hunt_test.go b/interp/errors_vuln_hunt_test.go new file mode 100644 index 00000000..08b780e6 --- /dev/null +++ b/interp/errors_vuln_hunt_test.go @@ -0,0 +1,130 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: errors (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runErrorsVulnHuntScript(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + prog, err := ParseScript(script, "errors_vuln_hunt.sh") + require.NoError(t, err) + + err = r.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_EmptyCommandExpansionsStaySilent(t *testing.T) { + stdout, stderr, code, err := runErrorsVulnHuntScript(t, "x=''\n$x\necho status=$?\nA=$(printf '\\n')\n$A\necho second=$?\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=0\nsecond=0\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_ValidationErrorsPreventPartialExecution(t *testing.T) { + for name, script := range map[string]string{ + "case": "echo before\ncase x in x) echo BAD;; esac\necho after\n", + "function": "echo before\nf() { echo BAD; }\necho after\n", + "process": "echo before\ncat <(echo BAD)\necho after\n", + "arith": "echo before\necho $((1+1))\necho after\n", + } { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runErrorsVulnHuntScript(t, script) + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.NotEmpty(t, stderr) + assert.NotContains(t, stdout+stderr, "BAD") + assert.NotContains(t, stdout, "before") + assert.NotContains(t, stdout, "after") + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_ErrorStatusPropagates(t *testing.T) { + stdout, stderr, code, err := runErrorsVulnHuntScript(t, "(no_such_cmd_xyz)\necho subshell=$?\necho result=[$(unknown_cmd_xyz)]\necho cmdsubst=$?\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "subshell=127\nresult=[]\ncmdsubst=0\n", stdout) + assert.Contains(t, stderr, "no_such_cmd_xyz") + assert.Contains(t, stderr, "unknown_cmd_xyz") +} + +func TestVulnHuntShellFeatureCompositionAttack_ErrorRedirectionAndPipelineSemantics(t *testing.T) { + stdout, stderr, code, err := runErrorsVulnHuntScript(t, "no_such_cmd 2>/dev/null\necho redir=$?\nunknown_pipe_left | cat >/dev/null\necho left=$?\necho hi | unknown_pipe_right\necho right=$?\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "redir=127\nleft=0\nright=127\n", stdout) + assert.NotContains(t, stderr, "no_such_cmd") + assert.Contains(t, stderr, "unknown_pipe_left") + assert.Contains(t, stderr, "unknown_pipe_right") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_ParseAndValidationErrorsStayOnStderr(t *testing.T) { + _, err := ParseScript(strings.Repeat(" ", MaxScriptBytes+1), "errors_oversized.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + + stdout, stderr, code, runErr := runErrorsVulnHuntScript(t, "echo before\ncase x in x) echo BAD;; esac\n") + require.NoError(t, runErr) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_FailedErrorsDoNotCorruptLastStatus(t *testing.T) { + stdout, stderr, code, err := runErrorsVulnHuntScript(t, "echo first\nno_such_cmd\necho after=$?\nfalse\necho false=$?\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "first\nafter=127\nfalse=1\n", stdout) + assert.Contains(t, stderr, "no_such_cmd") +} + +func TestVulnHuntShellFeatureSignalContext_ErrorLoopHonorsTimeout(t *testing.T) { + r, err := New(StdIO(nil, io.Discard, io.Discard), allowAllCommandsOpt(), MaxExecutionTime(25*time.Millisecond)) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + prog, err := ParseScript("while true; do no_such_cmd; done\n", "errors_timeout.sh") + require.NoError(t, err) + + err = r.Run(context.Background(), prog) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} From 79cdc48c18ab1af9a9586f128b0abf1a65f404ef Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 19:03:07 +0200 Subject: [PATCH 41/73] test: add uniq vuln hunt regressions --- builtins/uniq/uniq_vuln_hunt_test.go | 182 +++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 builtins/uniq/uniq_vuln_hunt_test.go diff --git a/builtins/uniq/uniq_vuln_hunt_test.go b/builtins/uniq/uniq_vuln_hunt_test.go new file mode 100644 index 00000000..70f5e3ac --- /dev/null +++ b/builtins/uniq/uniq_vuln_hunt_test.go @@ -0,0 +1,182 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: uniq (builtin) + +package uniq_test + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinFlagDrivenExploit_OutputFileOperandRejectedBeforeWrite(t *testing.T) { + dir := t.TempDir() + secret := t.TempDir() + writeFile(t, dir, "in.txt", "a\na\n") + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret\n"), 0o644)) + + _, stderr, code := cmdRun(t, "uniq in.txt out.txt", dir) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "extra operand") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + stdout, stderr, code := runScript(t, "uniq "+secretPath+" out.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "extra operand") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_DangerousWriteAndFollowFlagsRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "in.txt", "a\na\n") + + for _, script := range []string{ + "uniq --output=out.txt in.txt", + "uniq --files0-from=list.txt", + "uniq --follow in.txt", + "uniq -x in.txt", + `flag="--output=out.txt"; uniq $flag in.txt`, + } { + t.Run(script, func(t *testing.T) { + _, stderr, code := cmdRun(t, script, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "uniq:") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + }) + } + + stdout, stderr, code := cmdRun(t, "uniq --help --output=out.txt", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: uniq") + assert.Empty(t, stderr) + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_NumericValuesReportBeforeHelp(t *testing.T) { + dir := t.TempDir() + + for _, tc := range []struct { + script string + want string + }{ + {"uniq -f nope --help", "invalid number of fields to skip"}, + {"uniq -s -1 --help", "invalid number of bytes to skip"}, + {"uniq -w '' --help", "invalid number of bytes to compare"}, + } { + t.Run(tc.script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, tc.script, dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, tc.want) + }) + } +} + +func TestVulnHuntBuiltinIntegerOverflow_MethodPrefixesStayDocumented(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "in.txt", "a\na\nb\n") + + stdout, stderr, code := cmdRun(t, "uniq --group=p in.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "\na\na\n\nb\n", stdout) + assert.Empty(t, stderr) + + stdout, stderr, code = cmdRun(t, "uniq --all-repeated=s in.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "a\na\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinFileAccessBypass_OutsidePathsAndSymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on many Windows builders") + } + + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret\n"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(secret, "secret.txt"), filepath.Join(allowed, "link.txt"))) + + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + stdout, stderr, code := runScript(t, "uniq "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.NotContains(t, stdout, "secret") + assert.Contains(t, stderr, "uniq:") + + stdout, stderr, code = runScript(t, "uniq link.txt", allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.NotContains(t, stdout, "secret") + assert.Contains(t, stderr, "path escapes") +} + +func TestVulnHuntBuiltinResourceExhaustion_NulDelimitedLongRecordFailsClosed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "long.bin"), []byte(strings.Repeat("A", 1<<20+1)), 0o644)) + + _, stderr, code := cmdRun(t, "uniq -z long.bin", dir) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "token too long") +} + +func TestVulnHuntBuiltinSpecialFiles_DevZeroLineModeFailsAtLineCap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, stderr, code := runScriptCtx(ctx, t, "uniq /dev/zero", "", interp.AllowedPaths([]string{"/dev"})) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "token too long") +} + +func TestVulnHuntBuiltinSpecialFiles_ZeroTerminatedDevZeroHonorsConfiguredTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader("uniq -z /dev/zero"), "") + require.NoError(t, err) + + var stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(nil, io.Discard, &stderr), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.AllowedPaths([]string{"/dev"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + start := time.Now() + err = runner.Run(context.Background(), prog) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 2*time.Second) +} From dd7e8343edfdbd2e6be742cbb3bb278b1881b862 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 19:16:21 +0200 Subject: [PATCH 42/73] test: add function vuln hunt regressions --- interp/function_vuln_hunt_test.go | 240 +++++++++++++++++++++++++ interp/function_vuln_hunt_unix_test.go | 54 ++++++ 2 files changed, 294 insertions(+) create mode 100644 interp/function_vuln_hunt_test.go create mode 100644 interp/function_vuln_hunt_unix_test.go diff --git a/interp/function_vuln_hunt_test.go b/interp/function_vuln_hunt_test.go new file mode 100644 index 00000000..4f017c94 --- /dev/null +++ b/interp/function_vuln_hunt_test.go @@ -0,0 +1,240 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: function (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runFunctionVulnHuntScript(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "function_vuln_hunt.sh") + if err != nil { + return "", err.Error() + "\n", 2, nil + } + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + err = r.Run(ctx, prog) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_FunctionTokensFromExpansionNotReparsed(t *testing.T) { + stdout, stderr, code, err := runFunctionVulnHuntScript(t, "PAYLOAD='f() { echo BAD; }'\n$PAYLOAD\necho status=$?\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=127\n", stdout) + assert.Contains(t, stderr, "unknown command") + assert.NotContains(t, stdout+stderr, "BAD") +} + +func TestVulnHuntShellFeatureExpansionChain_FunctionTextInHeredocIsData(t *testing.T) { + script := "cat <<'EOF'\nf() { echo BAD; }\nEOF\n" + + stdout, stderr, code, err := runFunctionVulnHuntScript(t, script, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "f() { echo BAD; }\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_FunctionFormsRejectedBeforeExecution(t *testing.T) { + tests := map[string]string{ + "paren": "echo before\nf() { echo BAD; }\necho after\n", + "function_keyword": "echo before\nfunction f { echo BAD; }\necho after\n", + "function_paren": "echo before\nfunction f() { echo BAD; }\necho after\n", + "newline_body": "echo before\nf()\n{\necho BAD\n}\necho after\n", + "if_body": "echo before\nf() { if true; then echo BAD; fi; }\necho after\n", + "loop_body": "echo before\nf() for i in 1; do echo BAD; done\necho after\n", + "nested": "echo before\nouter() { inner() { echo BAD; }; inner; }\necho after\n", + "portable_name": "echo before\nbad_name() { echo BAD; }\necho after\n", + } + + for name, script := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runFunctionVulnHuntScript(t, script, "") + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout, "whole-file validation must reject before any echo runs") + assert.Contains(t, stderr, "function declarations are not supported") + }) + } +} + +func TestVulnHuntShellFeatureParserConfusion_DeepFunctionNestingRejectedCleanly(t *testing.T) { + var nested strings.Builder + for i := 0; i < 3000; i++ { + nested.WriteString("f") + nested.WriteString(strconv.Itoa(i)) + nested.WriteString("() {\n") + } + nested.WriteString("echo BAD\n") + for i := 0; i < 3000; i++ { + nested.WriteString("}\n") + } + + stdout, stderr, code, err := runFunctionVulnHuntScript(t, nested.String(), "") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "function declarations are not supported") +} + +func TestVulnHuntShellFeatureParserConfusion_DeepFunctionBodyCommandSubstRejectedCleanly(t *testing.T) { + var script strings.Builder + script.WriteString("f() { echo ") + for i := 0; i < 1000; i++ { + script.WriteString("$(echo ") + } + script.WriteString("BAD") + for i := 0; i < 1000; i++ { + script.WriteByte(')') + } + script.WriteString("\n}\n") + + stdout, stderr, code, err := runFunctionVulnHuntScript(t, script.String(), "") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "function declarations are not supported") +} + +func TestVulnHuntShellFeatureSubshellIsolation_FunctionFailureDoesNotPoisonReusableRunner(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "f() { echo BAD; }\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "function declarations are not supported") + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "echo clean\n")) + require.NoError(t, err) + assert.Equal(t, "clean\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_OversizedFunctionScriptRejectedBeforeParse(t *testing.T) { + _, err := ParseScript("f() {\n"+strings.Repeat("x", MaxScriptBytes+1)+"\n}\n", "oversized_function.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") +} + +func TestVulnHuntShellFeatureCompositionAttack_FunctionRedirectionsDoNotOpenOrExpand(t *testing.T) { + dir := t.TempDir() + scripts := []string{ + "f() { echo BAD; } > out.txt\necho after\n", + "f() { echo BAD; } > /dev/null\necho after\n", + `f() { echo BAD; } > "$(echo out.txt)"` + "\necho after\n", + } + + for _, script := range scripts { + t.Run(script, func(t *testing.T) { + stdout, stderr, code, err := runFunctionVulnHuntScript(t, script, dir, AllowedPaths([]string{dir})) + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "function declarations are not supported") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + }) + } +} + +func TestVulnHuntShellFeatureCompositionAttack_FunctionInCommandSubstitutionRejectedBeforeParentRuns(t *testing.T) { + stdout, stderr, code, err := runFunctionVulnHuntScript(t, `echo before +x=$(f() { echo BAD; }; f) +echo after +`, "") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "function declarations are not supported") +} + +func TestVulnHuntShellFeatureReadonlyBypass_FunctionBodyDoesNotMutateParent(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "f() { readonly RO_VAR=1; RO_VAR=bad; }\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "RO_VAR=ok\necho $RO_VAR\n")) + require.NoError(t, err) + assert.Equal(t, "ok\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureRedirectionChain_FunctionInputRedirectDoesNotReadOutsideAllowedPaths(t *testing.T) { + allowed := t.TempDir() + secretDir := t.TempDir() + secretPath := filepath.Join(secretDir, "secret.txt") + require.NoError(t, os.WriteFile(secretPath, []byte("secret\n"), 0o644)) + + script := "f() { echo BAD; } < " + shellQuoteFunctionVulnHunt(secretPath) + "\necho after\n" + stdout, stderr, code, err := runFunctionVulnHuntScript(t, script, allowed, AllowedPaths([]string{allowed})) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "function declarations are not supported") +} + +func shellQuoteFunctionVulnHunt(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/interp/function_vuln_hunt_unix_test.go b/interp/function_vuln_hunt_unix_test.go new file mode 100644 index 00000000..56d03a70 --- /dev/null +++ b/interp/function_vuln_hunt_unix_test.go @@ -0,0 +1,54 @@ +//go:build unix + +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: function (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntShellFeatureRedirectionChain_FunctionInputRedirectFifoDoesNotBlock(t *testing.T) { + dir := t.TempDir() + fifo := filepath.Join(dir, "in.fifo") + require.NoError(t, syscall.Mkfifo(fifo, 0o600)) + + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), AllowedPaths([]string{dir})) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + + done := make(chan error, 1) + prog := parseScript(t, "f() { echo BAD; } < in.fifo\necho after\n") + go func() { + done <- r.Run(context.Background(), prog) + }() + + select { + case err = <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("function declaration with FIFO input redirection blocked before validation") + } + + var status ExitStatus + require.True(t, errors.As(err, &status), "got err %v", err) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "function declarations are not supported") +} From 141334ebf01701818566df958bee8ebbacdc1efc Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 19:35:36 +0200 Subject: [PATCH 43/73] test: add command substitution vuln hunt regressions --- .../command_substitution_vuln_hunt_test.go | 192 ++++++++++++++++++ ...ommand_substitution_vuln_hunt_unix_test.go | 39 ++++ 2 files changed, 231 insertions(+) create mode 100644 interp/tests/command_substitution_vuln_hunt_test.go create mode 100644 interp/tests/command_substitution_vuln_hunt_unix_test.go diff --git a/interp/tests/command_substitution_vuln_hunt_test.go b/interp/tests/command_substitution_vuln_hunt_test.go new file mode 100644 index 00000000..f7c6dcc7 --- /dev/null +++ b/interp/tests/command_substitution_vuln_hunt_test.go @@ -0,0 +1,192 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: command_substitution (shell-feature) + +package tests_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func cmdSubstVHMkdir(t *testing.T, base, name string) string { + t.Helper() + dir := filepath.Join(base, name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + return dir +} + +func cmdSubstVHWriteFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) +} + +func TestVulnHuntShellFeatureExpansionChain_CommandSubstOutputNotReparsed(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdSubstRun(t, "payload=$(printf 'echo SAFE; echo HACKED')\n$payload\n", dir) + + assert.Equal(t, 0, code) + assert.Equal(t, "SAFE; echo HACKED\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureExpansionChain_CatShortcutExpandedPathStillSandboxed(t *testing.T) { + root := t.TempDir() + allowed := cmdSubstVHMkdir(t, root, "allowed") + outside := cmdSubstVHMkdir(t, root, "outside") + cmdSubstVHWriteFile(t, outside, "secret.txt", "leak") + + stdout, stderr, code := cmdSubstRun(t, + "name=$(printf '../outside/secret.txt')\n"+ + "x=$(<$name)\n"+ + "echo \"[$x]\"\n", + allowed, + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "[]\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout+stderr, "leak") +} + +func TestVulnHuntShellFeatureParserConfusion_BlockedSyntaxInsideCommandSubstPreventsExecution(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdSubstRun(t, "echo before\nx=$(readonly X=1; echo bad)\necho after\n", dir) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "readonly is not supported") +} + +func TestVulnHuntShellFeatureParserConfusion_DeepNestedCommandSubstCompletesCleanly(t *testing.T) { + dir := t.TempDir() + var script strings.Builder + script.WriteString("echo ") + for range 2000 { + script.WriteString("$(echo ") + } + script.WriteString("ok") + for range 2000 { + script.WriteByte(')') + } + script.WriteByte('\n') + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stdout, stderr, code := cmdSubstRunCtx(ctx, t, script.String(), dir) + + assert.Equal(t, 0, code) + assert.Equal(t, "ok\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_NULInCommandSubstSourceIsDataNotSyntax(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdSubstRun(t, "echo \"$(printf before\x00after)\"\n", dir) + + assert.Equal(t, 0, code) + assert.Equal(t, "beforeafter\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureSubshellIsolation_CommandSubstStateDoesNotLeak(t *testing.T) { + root := t.TempDir() + allowed := cmdSubstVHMkdir(t, root, "allowed") + cmdSubstVHMkdir(t, allowed, "child") + + stdout, stderr, code := cmdSubstRun(t, + "VAR=parent\n"+ + "captured=$(VAR=child; cd child; printf \"$VAR\")\n"+ + "printf 'captured=%s parent=%s pwd=%s\\n' \"$captured\" \"$VAR\" \"$PWD\"\n", + allowed, + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "captured=child parent=parent pwd="+allowed+"\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureSubshellIsolation_BreakInCommandSubstDoesNotBreakParentLoop(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdSubstRun(t, + "while true; do\n"+ + " out=$(break; echo child)\n"+ + " echo \"$out\"\n"+ + " break\n"+ + "done\n"+ + "echo parent\n", + dir, + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "\nparent\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_CatShortcutDevZeroStopsAtCaptureCap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("/dev/zero is Unix-specific") + } + + var stdout, stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(nil, &stdout, &stderr), + interp.AllowedPaths([]string{"/dev"}), + interp.AllowedCommands([]string{"rshell:cat", "rshell:echo"}), + interp.MaxExecutionTime(2*time.Second), + ) + require.NoError(t, err) + defer runner.Close() + + prog, err := interp.ParseScript("x=$( Date: Wed, 20 May 2026 19:55:33 +0200 Subject: [PATCH 44/73] test: add brace group vuln hunt regressions --- interp/brace_group_cyber3_vuln_hunt_test.go | 149 ++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 interp/brace_group_cyber3_vuln_hunt_test.go diff --git a/interp/brace_group_cyber3_vuln_hunt_test.go b/interp/brace_group_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..b4ace1b6 --- /dev/null +++ b/interp/brace_group_cyber3_vuln_hunt_test.go @@ -0,0 +1,149 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: brace_group (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runBraceGroupCyber3Script(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + prog, err := ParseScript(script, "brace_group_cyber3_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), prog) + code := 0 + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureExpansionChain_BraceGroupOutputNotReparsed(t *testing.T) { + stdout, stderr, code, err := runBraceGroupCyber3Script(t, + "PAYLOAD='echo SAFE; echo HACKED'\n{ $PAYLOAD; }\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "SAFE; echo HACKED\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureExpansionChain_BraceGroupDoesNotLeakOutsideGlob(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.MkdirAll(allowed, 0o755)) + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("leak"), 0o644)) + + stdout, stderr, code, err := runBraceGroupCyber3Script(t, + "{ echo ../outside/*; }\n", + allowed, + AllowedPaths([]string{allowed}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "../outside/*\n", stdout) + assert.NotContains(t, stdout+stderr, "leak") +} + +func TestVulnHuntShellFeatureParserConfusion_BlockedSyntaxInBraceGroupPreventsExecution(t *testing.T) { + stdout, stderr, code, err := runBraceGroupCyber3Script(t, + "echo before\n{ readonly X=1; echo bad; }\necho after\n", "") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "readonly is not supported") +} + +func TestVulnHuntShellFeatureParserConfusion_DeepValidBraceGroupsDoNotOverflow(t *testing.T) { + var script strings.Builder + for range 2000 { + script.WriteString("{ ") + } + script.WriteString("echo ok") + for range 2000 { + script.WriteString("; }") + } + script.WriteByte('\n') + + stdout, stderr, code, err := runBraceGroupCyber3Script(t, script.String(), "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "ok\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureSubshellIsolation_BraceScopeDependsOnPipelineContext(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "child") + require.NoError(t, os.MkdirAll(child, 0o755)) + + stdout, stderr, code, err := runBraceGroupCyber3Script(t, + "VAR=parent\n"+ + "{ VAR=brace; cd child; }\n"+ + "echo top=$VAR:$PWD\n"+ + "{ VAR=pipeline; cd ..; echo stage=$VAR:$PWD; } | cat\n"+ + "echo after=$VAR:$PWD\n", + root, + AllowedPaths([]string{root}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "top=brace:"+child+"\nstage=pipeline:"+root+"\nafter=brace:"+child+"\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_BraceGroupVariableStorageCapDoesNotAssign(t *testing.T) { + large := strings.Repeat("x", MaxVarBytes+1) + stdout, stderr, code, err := runBraceGroupCyber3Script(t, + "{ BIG="+large+"; echo SHOULD_NOT_PRINT; }\necho after=${BIG}\n", "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "SHOULD_NOT_PRINT\nafter=\n", stdout) + assert.Contains(t, stderr, "value too large") +} + +func TestVulnHuntShellFeatureSignalContext_BraceGroupNoOutputLoopCancels(t *testing.T) { + var stdout, stderr bytes.Buffer + r := newTimeoutRunner(t, StdIO(nil, &stdout, &stderr), MaxExecutionTime(50*time.Millisecond)) + + err := r.Run(context.Background(), parseScript(t, "{ while true; do true; done; }\n")) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout.String()) +} From 088c0b19ff0fada0cbcdf2b5043301081c3ed2f7 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 20:05:34 +0200 Subject: [PATCH 45/73] test: add cat vuln hunt regressions --- builtins/cat/cat_vuln_hunt_test.go | 163 +++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 builtins/cat/cat_vuln_hunt_test.go diff --git a/builtins/cat/cat_vuln_hunt_test.go b/builtins/cat/cat_vuln_hunt_test.go new file mode 100644 index 00000000..006b43cf --- /dev/null +++ b/builtins/cat/cat_vuln_hunt_test.go @@ -0,0 +1,163 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: cat (builtin) + +package cat_test + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + catcmd "github.com/DataDog/rshell/builtins/cat" + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinFlagDrivenExploit_DangerousFlagsRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "in.txt", "safe\n") + + for _, script := range []string{ + "cat --output=out.txt in.txt", + "cat --follow in.txt", + "cat --files0-from=list.txt", + "cat -f in.txt", + `flag="--output=out.txt"; cat $flag in.txt`, + } { + t.Run(script, func(t *testing.T) { + _, stderr, code := cmdRun(t, script, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cat:") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + }) + } + + stdout, stderr, code := cmdRun(t, "cat --help --output=out.txt", dir) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: cat") + assert.Empty(t, stderr) + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_DoubleDashAndDashStdinStayDataOnly(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "-n", "flag-looking-name\n") + writeFile(t, dir, "stdin.txt", "stdin-once\n") + + stdout, stderr, code := cmdRun(t, "cat -- -n", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "flag-looking-name\n", stdout) + assert.Empty(t, stderr) + + stdout, stderr, code = cmdRun(t, "cat - - < stdin.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "stdin-once\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinFileAccessBypass_OutsidePathsAndSymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on many Windows builders") + } + + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("secret\n"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(secret, "secret.txt"), filepath.Join(allowed, "link.txt"))) + + secretPath := strings.ReplaceAll(filepath.Join(secret, "secret.txt"), `\`, `/`) + stdout, stderr, code := runScript(t, "cat "+secretPath, allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.NotContains(t, stdout, "secret") + assert.Contains(t, stderr, "cat:") + + stdout, stderr, code = runScript(t, "cat link.txt", allowed, interp.AllowedPaths([]string{allowed})) + assert.Equal(t, 1, code) + assert.NotContains(t, stdout, "secret") + assert.Contains(t, stderr, "cat:") +} + +func TestVulnHuntBuiltinResourceExhaustion_LineModeLongRecordFailsClosed(t *testing.T) { + dir := t.TempDir() + content := strings.Repeat("A", catcmd.MaxLineBytes+1) + require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), []byte(content), 0o644)) + + stdout, stderr, code := cmdRun(t, "cat -n huge.txt", dir) + + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "cat:") + assert.Contains(t, stderr, "token too long") +} + +func TestVulnHuntBuiltinSpecialFiles_DevZeroLineModeFailsAtLineCap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + stdout, stderr, code := runScriptCtx(ctx, t, "cat -n /dev/zero", "", interp.AllowedPaths([]string{"/dev"})) + + require.NoError(t, ctx.Err()) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "token too long") +} + +func TestVulnHuntBuiltinSpecialFiles_RawDevZeroHonorsConfiguredTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + + prog, err := interp.ParseScript("cat /dev/zero", "cat_devzero_timeout.sh") + require.NoError(t, err) + + var stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(nil, io.Discard, &stderr), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.AllowedPaths([]string{"/dev"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + start := time.Now() + err = runner.Run(context.Background(), prog) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 2*time.Second) +} + +func TestVulnHuntBuiltinComposition_BrokenPipeTerminatesRawCat(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no /dev/zero on Windows") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + stdout, stderr, code := runScriptCtx(ctx, t, "cat /dev/zero | head -c 1", "", interp.AllowedPaths([]string{"/dev"})) + + require.NoError(t, ctx.Err()) + assert.Equal(t, 0, code) + assert.Len(t, stdout, 1) + assert.Empty(t, stderr) +} From 69414251768b277fbb81ce761698cf0c2a9c5dad Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 20:13:44 +0200 Subject: [PATCH 46/73] test: add break vuln hunt regressions --- builtins/break/break_vuln_hunt_test.go | 138 +++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 builtins/break/break_vuln_hunt_test.go diff --git a/builtins/break/break_vuln_hunt_test.go b/builtins/break/break_vuln_hunt_test.go new file mode 100644 index 00000000..e6d76e22 --- /dev/null +++ b/builtins/break/break_vuln_hunt_test.go @@ -0,0 +1,138 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: break (builtin) + +package breakcmd_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func runBreakVulnHunt(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, "", opts...) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_BreakHelpDoesNotMutateLoopControl(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, + "for i in 1 2; do break --help; echo after-$i; done; echo done\n") + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, 2, strings.Count(stdout, "break: break [n]")) + assert.Contains(t, stdout, "after-1\n") + assert.Contains(t, stdout, "after-2\n") + assert.True(t, strings.HasSuffix(stdout, "done\n")) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_OutsideLoopBreakDoesNotPoisonFlow(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, + "break\n"+ + "echo status=$?\n"+ + "false || break\n"+ + "echo after-or\n"+ + "break && echo after-and\n"+ + "echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "status=0\nafter-or\nafter-and\ndone\n", stdout) + assert.Equal(t, 3, strings.Count(stderr, "break is only useful in a loop")) +} + +func TestVulnHuntBuiltinIntegerOverflow_InvalidArgumentsAbortOrBreakCompatibly(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, "for i in 1; do break abc; echo after; done; echo done\n") + assert.Equal(t, 128, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "break: abc: numeric argument required") + + stdout, stderr, code = runBreakVulnHunt(t, "for i in 1; do break 1 2; echo after; done; echo done\n") + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "break: too many arguments") + + for _, arg := range []string{"0", "-1"} { + t.Run(arg, func(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, + "for i in 1; do echo before; break "+arg+"; echo after; done; echo done\n") + assert.Equal(t, 0, code) + assert.Equal(t, "before\ndone\n", stdout) + assert.Contains(t, stderr, "loop count out of range") + }) + } +} + +func TestVulnHuntBuiltinIntegerOverflow_HugeBreakLevelsClampAtOutermost(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, + "for i in 1 2; do\n"+ + " for j in a b; do\n"+ + " echo in-$i-$j\n"+ + " break 999999\n"+ + " echo inner-unreachable\n"+ + " done\n"+ + " echo outer-unreachable\n"+ + "done\n"+ + "echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "in-1-a\ndone\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinSubshellIsolation_PipelineStagesDoNotBreakParent(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, + "for i in 1; do break | cat | cat; echo after-pipe; break; done; echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "after-pipe\ndone\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinSubshellIsolation_GroupedPipelineStageDiagnosesAndContinues(t *testing.T) { + stdout, stderr, code := runBreakVulnHunt(t, + "for i in 1; do { break; echo grouped; } | cat; echo after-group; break; done; echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "grouped\nafter-group\ndone\n", stdout) + assert.Contains(t, stderr, "break is only useful in a loop") +} + +func TestVulnHuntBuiltinStateCorruption_RunnerReuseAfterBreakHasNoStaleCounter(t *testing.T) { + var stdout, stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(nil, &stdout, &stderr), + interpoption.AllowAllCommands().(interp.RunnerOption), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + first, err := interp.ParseScript("for i in 1; do break; done\n", "break_reuse_1.sh") + require.NoError(t, err) + err = runner.Run(context.Background(), first) + require.NoError(t, err) + + second, err := interp.ParseScript("echo second\n", "break_reuse_2.sh") + require.NoError(t, err) + err = runner.Run(context.Background(), second) + var status interp.ExitStatus + if errors.As(err, &status) { + t.Fatalf("second run unexpectedly exited with %d", status) + } + require.NoError(t, err) + assert.Equal(t, "second\n", stdout.String()) + assert.Empty(t, stderr.String()) +} From 19b2a5d66adfc3582c52e3b3bb5ac557fe14b4a0 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 20:27:27 +0200 Subject: [PATCH 47/73] test: add parser lexer vuln hunt regressions --- interp/parser_lexer_vuln_hunt_test.go | 214 ++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 interp/parser_lexer_vuln_hunt_test.go diff --git a/interp/parser_lexer_vuln_hunt_test.go b/interp/parser_lexer_vuln_hunt_test.go new file mode 100644 index 00000000..42ae0d1b --- /dev/null +++ b/interp/parser_lexer_vuln_hunt_test.go @@ -0,0 +1,214 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: parser-lexer (subsystem) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runParserLexerVulnHuntScript(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "parser_lexer_vuln_hunt.sh") + if err != nil { + return "", err.Error() + "\n", 2, nil + } + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + }, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntSubsystemInvariantViolation_ControlBytesAndLineEndingsDeterministic(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "crlf_separates_statements": { + script: "echo one\r\necho two\r\n", + want: "one\ntwo\n", + }, + "nul_matches_bash_ignored_byte_model": { + script: "echo before\x00echo after\n", + want: "beforeecho after\n", + }, + "comment_before_crlf_stays_comment": { + script: "echo one # ignored\r\necho two\n", + want: "one\ntwo\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runParserLexerVulnHuntScript(t, tc.script) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, tc.want, stdout) + assert.Empty(t, stderr) + }) + } + + _, err := ParseScript("echo ok\xff\n", "invalid_utf8.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid UTF-8 encoding") +} + +func TestVulnHuntSubsystemInvariantViolation_UnsupportedSyntaxValidationPrecedesExecution(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "case_clause": { + script: "echo before\ncase x in x) echo hidden;; esac\necho after\n", + want: "case statements are not supported", + }, + "function_decl": { + script: "echo before\nf() { echo hidden; }\necho after\n", + want: "function declarations are not supported", + }, + "process_substitution": { + script: "echo before\ncat <(echo hidden)\necho after\n", + want: "process substitution is not supported", + }, + "background_execution": { + script: "echo before\necho hidden & echo after\n", + want: "background execution", + }, + "herestring": { + script: "echo before\ncat <<< hidden\necho after\n", + want: "<<< (herestring) is not supported", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runParserLexerVulnHuntScript(t, tc.script) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout, "whole-file validation must reject before earlier statements execute") + assert.Contains(t, stderr, tc.want) + }) + } +} + +func TestVulnHuntSubsystemResourceLimitBypass_MaxScriptBytesRejectsBeforeParser(t *testing.T) { + script := strings.Repeat("echo parser-lexer\n", MaxScriptBytes/len("echo parser-lexer\n")+1) + require.Greater(t, len(script), MaxScriptBytes) + + _, err := ParseScript(script, "oversized_parser_lexer.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + assert.Contains(t, err.Error(), "5 MiB") +} + +func TestVulnHuntSubsystemThreatModelCoverage_ProductionParsingUsesParseScript(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + require.True(t, ok) + repoRoot := filepath.Dir(filepath.Dir(file)) + + runtimeDirs := map[string]bool{ + "allowedpaths": true, + "builtins": true, + "cmd": true, + "interp": true, + "internal": true, + } + allowedDirectParserFiles := map[string]bool{ + filepath.Join("interp", "api.go"): true, + } + + var violations []string + for dir := range runtimeDirs { + root := filepath.Join(repoRoot, dir) + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + require.NoError(t, err) + if d.IsDir() { + switch d.Name() { + case ".git", "vendor": + return filepath.SkipDir + } + relDir, err := filepath.Rel(repoRoot, path) + require.NoError(t, err) + switch relDir { + case filepath.Join("builtins", "testutil"), filepath.Join("builtins", "tests"): + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, err := filepath.Rel(repoRoot, path) + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + if strings.Contains(string(data), "syntax.NewParser") && !allowedDirectParserFiles[rel] { + violations = append(violations, rel) + } + return nil + }) + require.NoError(t, err) + } + + assert.Empty(t, violations, "production parser use must go through interp.ParseScript for MaxScriptBytes enforcement") +} + +func TestVulnHuntSubsystemPanicStateCorruption_ValidationErrorDoesNotPoisonRunnerReuse(t *testing.T) { + blocked, err := ParseScript("case x in x) echo hidden;; esac\n", "blocked_case.sh") + require.NoError(t, err) + valid, err := ParseScript("echo ok\n", "valid_after_blocked.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + runErr := r.Run(context.Background(), blocked) + var status ExitStatus + require.ErrorAs(t, runErr, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "case statements are not supported") + + stdout.Reset() + stderr.Reset() + require.NoError(t, r.Run(context.Background(), valid)) + assert.Equal(t, "ok\n", stdout.String()) + assert.Empty(t, stderr.String()) +} From 049b0e0e33f87617ed61def554aa7fa5d8be04bb Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 20:45:47 +0200 Subject: [PATCH 48/73] test: add df mount enumeration vuln hunt regressions --- .../df_mount_enumeration_vuln_hunt_test.go | 210 ++++++++++++++++++ .../df/df_mount_enumeration_vuln_hunt_test.go | 108 +++++++++ .../diskstats/diskstats_vuln_hunt_test.go | 111 +++++++++ 3 files changed, 429 insertions(+) create mode 100644 analysis/df_mount_enumeration_vuln_hunt_test.go create mode 100644 builtins/df/df_mount_enumeration_vuln_hunt_test.go create mode 100644 builtins/internal/diskstats/diskstats_vuln_hunt_test.go diff --git a/analysis/df_mount_enumeration_vuln_hunt_test.go b/analysis/df_mount_enumeration_vuln_hunt_test.go new file mode 100644 index 00000000..f43f45f9 --- /dev/null +++ b/analysis/df_mount_enumeration_vuln_hunt_test.go @@ -0,0 +1,210 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// df-mount-enumeration. + +package analysis + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestVulnHuntSubsystemDfMountEnumeration_MountinfoPathHardcoded(t *testing.T) { + fset, file := parseProductionFileVH(t, "builtins/internal/diskstats/diskstats_linux.go") + + constName := "mountInfoPath" + constValue := "" + ast.Inspect(file, func(n ast.Node) bool { + spec, ok := n.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range spec.Names { + if name.Name != constName || i >= len(spec.Values) { + continue + } + if lit, ok := spec.Values[i].(*ast.BasicLit); ok { + constValue = strings.Trim(lit.Value, `"`) + } + } + return true + }) + if constValue != "/proc/self/mountinfo" { + t.Fatalf("mountInfoPath const = %q, want /proc/self/mountinfo", constValue) + } + + osOpenCalls := 0 + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || receiverName(sel.X) != "os" { + return true + } + if sel.Sel.Name != "Open" { + t.Fatalf("%s: diskstats Linux may only use direct os.Open, got os.%s", + fset.Position(sel.Pos()), sel.Sel.Name) + } + osOpenCalls++ + if len(call.Args) != 1 { + t.Fatalf("%s: os.Open has %d args, want 1", fset.Position(call.Pos()), len(call.Args)) + } + id, ok := call.Args[0].(*ast.Ident) + if !ok || id.Name != constName { + t.Fatalf("%s: os.Open argument = %s, want mountInfoPath const", + fset.Position(call.Args[0].Pos()), exprString(call.Args[0])) + } + return true + }) + if osOpenCalls != 1 { + t.Fatalf("diskstats Linux os.Open calls = %d, want exactly 1", osOpenCalls) + } +} + +func TestVulnHuntSubsystemDfMountEnumeration_ScannerBufferUsesMountInfoCap(t *testing.T) { + _, file := parseProductionFileVH(t, "builtins/internal/diskstats/diskstats_linux.go") + found := false + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Buffer" || len(call.Args) != 2 { + return true + } + id, ok := call.Args[1].(*ast.Ident) + if ok && id.Name == "maxMountInfoLine" { + found = true + } + return true + }) + if !found { + t.Fatal("parseMountInfo must call scanner.Buffer(..., maxMountInfoLine)") + } +} + +func TestVulnHuntSubsystemDfMountEnumeration_StatfsErrorsSkippedAndCtxChecked(t *testing.T) { + fset, file := parseProductionFileVH(t, "builtins/internal/diskstats/diskstats_linux.go") + var statfsPos token.Pos + var ctxErrBeforeStatfs bool + var statfsErrorContinues bool + + ast.Inspect(file, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Name.Name != "listImpl" { + return true + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if isSelectorCall(call, "ctx", "Err") && statfsPos == token.NoPos { + ctxErrBeforeStatfs = true + } + if isSelectorCall(call, "unix", "Statfs") { + statfsPos = call.Pos() + } + return true + }) + ast.Inspect(fn.Body, func(n ast.Node) bool { + ifstmt, ok := n.(*ast.IfStmt) + if !ok { + return true + } + if !containsSelectorCallInNode(ifstmt.Init, "unix", "Statfs") && + !containsSelectorCallInNode(ifstmt.Cond, "unix", "Statfs") { + return true + } + for _, stmt := range ifstmt.Body.List { + if branch, ok := stmt.(*ast.BranchStmt); ok && branch.Tok == token.CONTINUE { + statfsErrorContinues = true + } + } + return true + }) + return false + }) + if statfsPos == token.NoPos { + t.Fatal("unix.Statfs call not found in listImpl") + } + if !ctxErrBeforeStatfs { + t.Fatalf("%s: listImpl must check ctx.Err before statfs loop work", fset.Position(statfsPos)) + } + if !statfsErrorContinues { + t.Fatal("listImpl must continue, not return, when unix.Statfs fails") + } +} + +func TestVulnHuntSubsystemDfMountEnumeration_PlatformBackendsFailClosed(t *testing.T) { + other := readProductionFileVH(t, "builtins/internal/diskstats/diskstats_other.go") + if !strings.Contains(other, "//go:build !linux && !darwin") { + t.Fatal("diskstats_other.go must be restricted to non-Linux/non-Darwin platforms") + } + if !strings.Contains(other, "return nil, ErrNotSupported") { + t.Fatal("unsupported diskstats backend must return ErrNotSupported") + } + + darwin := readProductionFileVH(t, "builtins/internal/diskstats/diskstats_darwin.go") + if strings.Count(darwin, "unix.MNT_NOWAIT") < 2 { + t.Fatal("Darwin diskstats backend must use MNT_NOWAIT for both Getfsstat calls") + } + + windowsTest := readProductionFileVH(t, "builtins/df/df_windows_test.go") + if !strings.Contains(windowsTest, "TestDfNotSupportedOnWindows") || + !strings.Contains(windowsTest, "TestDfHelpAlwaysWorks") { + t.Fatal("df Windows tests must cover fail-closed enumeration and --help availability") + } +} + +func parseProductionFileVH(t *testing.T, rel string) (*token.FileSet, *ast.File) { + t.Helper() + path := filepath.Join(repoRoot(t), filepath.FromSlash(rel)) + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse %s: %v", rel, err) + } + return fset, file +} + +func readProductionFileVH(t *testing.T, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(repoRoot(t), filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(b) +} + +func isSelectorCall(call *ast.CallExpr, recv, name string) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + return ok && receiverName(sel.X) == recv && sel.Sel.Name == name +} + +func containsSelectorCallInNode(node ast.Node, recv, name string) bool { + if node == nil { + return false + } + found := false + ast.Inspect(node, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if ok && isSelectorCall(call, recv, name) { + found = true + } + return !found + }) + return found +} diff --git a/builtins/df/df_mount_enumeration_vuln_hunt_test.go b/builtins/df/df_mount_enumeration_vuln_hunt_test.go new file mode 100644 index 00000000..9e13e2f2 --- /dev/null +++ b/builtins/df/df_mount_enumeration_vuln_hunt_test.go @@ -0,0 +1,108 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// df-mount-enumeration. + +package df + +import ( + "bytes" + "strings" + "testing" + + "github.com/DataDog/rshell/builtins" + "github.com/DataDog/rshell/builtins/internal/diskstats" +) + +func TestVulnHuntSubsystemInvariantViolation_ControlMountCellsCannotForgeRows(t *testing.T) { + for _, tc := range []struct { + in string + want string + }{ + {"src\nforged", "src?forged"}, + {"src\tforged", "src?forged"}, + {"src\rforged", "src?forged"}, + {"src\x00forged", "src?forged"}, + {"src\x7fforged", "src?forged"}, + } { + if got := replaceUnprintable(tc.in); got != tc.want { + t.Fatalf("replaceUnprintable(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestVulnHuntSubsystemPanicStateCorruption_WriteOutputAdversarialRows(t *testing.T) { + var stdout, stderr bytes.Buffer + flags := &flags{ + posix: boolPtrVH(true), + printType: boolPtrVH(true), + inodes: boolPtrVH(false), + total: boolPtrVH(true), + } + mounts := []diskstats.Mount{ + { + Source: "src\nFORGED_DF_ROW=1", DevID: "8:1", MountPoint: "/mnt\tbad", FSType: "ext4", + Total: ^uint64(0), Used: ^uint64(0), Free: ^uint64(0), + }, + { + Source: "dup", DevID: "8:1", MountPoint: "/longer/duplicate", FSType: "ext4", + Total: 1, Used: 1, Free: 0, + }, + } + + writeOutput(&builtins.CallContext{Stdout: &stdout, Stderr: &stderr}, mounts, flags, unitsK) + if stderr.Len() != 0 { + t.Fatalf("writeOutput wrote stderr: %q", stderr.String()) + } + out := stdout.String() + if strings.Contains(out, "\nFORGED_DF_ROW") { + t.Fatalf("control byte in mount source forged a row:\n%s", out) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if got, want := len(lines), 4; got != want { + t.Fatalf("output line count = %d, want header + 2 rows + total; output:\n%s", got, out) + } +} + +func TestVulnHuntSubsystemResourceLimitBypass_DfArithmeticSaturates(t *testing.T) { + maxU := ^uint64(0) + if got := saturatingAdd(maxU, 1); got != maxU { + t.Fatalf("saturatingAdd(max, 1) = %d, want max", got) + } + if got := percentUsed(maxU, maxU); got != "50%" { + t.Fatalf("percentUsed(max, max) = %q, want 50%%", got) + } + if got := percentUsed(1, maxU); got != "1%" { + t.Fatalf("percentUsed(1, max) = %q, want 1%%", got) + } + if got := formatCount(maxU, unitsK, false); got == "0" || got == "" { + t.Fatalf("formatCount(max, unitsK) wrapped or emptied: %q", got) + } +} + +func TestVulnHuntSubsystemRaceToctou_DedupKeepsShortestAndEmptyDevID(t *testing.T) { + in := []diskstats.Mount{ + {Source: "bind", DevID: "0:25", MountPoint: "/etc/resolv.conf"}, + {Source: "bind", DevID: "0:25", MountPoint: "/etc/hosts"}, + {Source: "nodev-a", DevID: "", MountPoint: "/a"}, + {Source: "nodev-b", DevID: "", MountPoint: "/b"}, + } + out := filterMounts(append([]diskstats.Mount(nil), in...), &flags{all: boolPtrVH(false)}) + if len(out) != 3 { + t.Fatalf("filterMounts kept %d rows, want duplicate collapsed plus two empty-DevID rows", len(out)) + } + var sawHosts, sawA, sawB bool + for _, m := range out { + sawHosts = sawHosts || m.MountPoint == "/etc/hosts" + sawA = sawA || m.MountPoint == "/a" + sawB = sawB || m.MountPoint == "/b" + } + if !sawHosts || !sawA || !sawB { + t.Fatalf("dedup result lost required rows: %#v", out) + } +} + +func boolPtrVH(v bool) *bool { return &v } diff --git a/builtins/internal/diskstats/diskstats_vuln_hunt_test.go b/builtins/internal/diskstats/diskstats_vuln_hunt_test.go new file mode 100644 index 00000000..474838bc --- /dev/null +++ b/builtins/internal/diskstats/diskstats_vuln_hunt_test.go @@ -0,0 +1,111 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// df-mount-enumeration. + +package diskstats + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestVulnHuntSubsystemInvariantViolation_RemoteMountsClassifiedBeforeStatfs(t *testing.T) { + for _, tc := range []struct { + name string + source string + fsType string + }{ + {"nfs source on generic fs", "server:/export", "auto"}, + {"sshfs source shape", "user@host:/path", "ext4"}, + {"smb unc source", "//server/share", "ext4"}, + {"nfs type", "/dev/local", "nfs4"}, + {"fuse sshfs type", "/dev/local", "fuse.sshfs"}, + {"fuse rclone type", "/dev/local", "fuse.rclone"}, + } { + t.Run(tc.name, func(t *testing.T) { + if !isRemoteSource(tc.source, tc.fsType) { + t.Fatalf("remote mount source=%q fsType=%q classified local", tc.source, tc.fsType) + } + }) + } +} + +func TestVulnHuntSubsystemResourceLimitBypass_ParseMountInfoCapsAndGenericErrors(t *testing.T) { + tooLong := strings.Repeat("x", maxMountInfoLine+1) + "\n" + mounts, err := parseMountInfo(context.Background(), strings.NewReader(tooLong)) + if !errors.Is(err, errLineTooLong) { + t.Fatalf("overlong mountinfo line error = %v, want errLineTooLong", err) + } + if len(mounts) != 0 { + t.Fatalf("overlong line returned %d mounts, want 0", len(mounts)) + } + if strings.Contains(err.Error(), "xxx") { + t.Fatalf("overlong error leaked raw line content: %q", err) + } + + var b strings.Builder + for range 5000 { + b.WriteString("malformed without separator\n") + } + mounts, err = parseMountInfo(context.Background(), strings.NewReader(b.String())) + if err != nil { + t.Fatalf("malformed-only stream returned unexpected error: %v", err) + } + if len(mounts) != 0 { + t.Fatalf("malformed-only stream returned %d mounts, want 0", len(mounts)) + } +} + +func TestVulnHuntSubsystemResourceLimitBypass_UnescapeDoesNotGrow(t *testing.T) { + for _, in := range []string{ + strings.Repeat(`\040`, 4096), + strings.Repeat(`\999`, 4096), + strings.Repeat(`\04`, 4096), + strings.Repeat(`\\`, 4096), + } { + if got := unescapeMountField(in); len(got) > len(in) { + t.Fatalf("unescapeMountField grew input: in=%d out=%d", len(in), len(got)) + } + } +} + +func TestVulnHuntSubsystemPanicStateCorruption_MalformedBinaryMountinfoNoPanic(t *testing.T) { + input := strings.Join([]string{ + "36 35 98:0 / /ok rw - ext4 /dev/x rw", + "36 35 98:0 / /nul\x00mount rw - ext4 /dev/x rw", + "36 35 98:0 / /\xff\xfe rw - ext4 /dev/x rw", + "\x7fELF\x02\x01\x01\x00 - ext4 src rw", + "36 - 98:0 - / / rw - ext4 /dev/sda1 rw", + "37 35 0:18 / /crlf rw - proc proc rw\r", + }, "\n") + "\n" + + mounts, err := parseMountInfo(context.Background(), strings.NewReader(input)) + if err != nil { + t.Fatalf("parseMountInfo returned error for malformed/binary mix: %v", err) + } + if len(mounts) == 0 { + t.Fatal("expected at least one valid mount from mixed input") + } + for i, m := range mounts { + if m.MountPoint == "" || m.FSType == "" { + t.Fatalf("mount %d has empty required fields: %#v", i, m) + } + } +} + +func TestVulnHuntSubsystemResourceLimitBypass_CanceledContextStopsParse(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := parseMountInfo(ctx, strings.NewReader("36 35 98:0 / / rw - ext4 /dev/x rw\n")) + if !errors.Is(err, context.Canceled) { + t.Fatalf("parseMountInfo canceled error = %v, want context.Canceled", err) + } +} From 0c8edc6768dab505695dc592331a416caf8d112b Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 21:21:19 +0200 Subject: [PATCH 49/73] test: add signal handling vuln hunt regressions --- cmd/rshell/signal_handling_vuln_hunt_test.go | 57 +++++++ .../signal_handling_cyber3_vuln_hunt_test.go | 146 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 cmd/rshell/signal_handling_vuln_hunt_test.go create mode 100644 interp/signal_handling_cyber3_vuln_hunt_test.go diff --git a/cmd/rshell/signal_handling_vuln_hunt_test.go b/cmd/rshell/signal_handling_vuln_hunt_test.go new file mode 100644 index 00000000..1df80963 --- /dev/null +++ b/cmd/rshell/signal_handling_vuln_hunt_test.go @@ -0,0 +1,57 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// signal-handling. + +package main + +import ( + "bytes" + "context" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestVulnHuntSubsystemSignalHandling_CLITimeoutUsesFixedDiagnostic(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run( + context.Background(), + []string{"--allow-all-commands", "--timeout=25ms", "-c", "while true; do echo x >/dev/null; done"}, + nil, + &stdout, + &stderr, + ) + + assert.Equal(t, exitCodeTimeout, code) + assert.Empty(t, stdout.String()) + assert.Equal(t, "error: execution timed out after 25ms\n", stderr.String()) +} + +func TestVulnHuntSubsystemSignalHandling_CLIStdinReadTimeoutReturnsPromptly(t *testing.T) { + pr, pw := io.Pipe() + t.Cleanup(func() { + _ = pw.Close() + _ = pr.Close() + }) + + var stdout, stderr bytes.Buffer + start := time.Now() + code := run( + context.Background(), + []string{"--timeout=25ms"}, + pr, + &stdout, + &stderr, + ) + + assert.Equal(t, exitCodeTimeout, code) + assert.Less(t, time.Since(start), time.Second) + assert.Empty(t, stdout.String()) + assert.Equal(t, "error: execution timed out after 25ms\n", stderr.String()) +} diff --git a/interp/signal_handling_cyber3_vuln_hunt_test.go b/interp/signal_handling_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..eeddb8dc --- /dev/null +++ b/interp/signal_handling_cyber3_vuln_hunt_test.go @@ -0,0 +1,146 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// signal-handling. + +package interp + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntSubsystemSignalHandling_TrapRedirectionCannotBypassSandbox(t *testing.T) { + allowed := t.TempDir() + blocked := t.TempDir() + blockedFile := filepath.Join(blocked, "secret.txt") + require.NoError(t, os.WriteFile(blockedFile, []byte("secret"), 0o600)) + + var stdout, stderr bytes.Buffer + r, err := New( + StdIO(nil, &stdout, &stderr), + AllowedPaths([]string{allowed}), + AllowedCommands([]string{"rshell:echo"}), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + script := fmt.Sprintf("trap 'echo pwned' INT < %q\necho after\n", blockedFile) + err = r.Run(context.Background(), parseScript(t, script)) + require.NoError(t, err) + + assert.Equal(t, "after\n", stdout.String()) + assert.Contains(t, stderr.String(), "permission denied") + assert.NotContains(t, stderr.String(), "pwned") +} + +func TestVulnHuntSubsystemSignalHandling_CommandSubstitutionCancelIsFatal(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New( + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + MaxExecutionTime(50*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + r.execHandler = func(ctx context.Context, _ []string) error { + <-ctx.Done() + return ctx.Err() + } + + err = r.Run(context.Background(), parseScript(t, "for x in $(slowcmd); do echo BAD; done")) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.NotContains(t, stdout.String(), "BAD") +} + +func TestVulnHuntSubsystemSignalHandling_BuiltinCtxCancelIsFatal(t *testing.T) { + pr, pw := io.Pipe() + t.Cleanup(func() { + _ = pw.Close() + _ = pr.Close() + }) + + var stderr bytes.Buffer + r, err := New( + StdIO(pr, io.Discard, &stderr), + allowAllCommandsOpt(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + err = r.Run(ctx, parseScript(t, "read x")) + require.ErrorIs(t, err, context.Canceled) + assert.Less(t, time.Since(start), time.Second) +} + +func TestVulnHuntSubsystemSignalHandling_ConcurrentPipelineStderrSerializesRows(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New( + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + MaxExecutionTime(time.Second), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + r.execHandler = func(ctx context.Context, args []string) error { + hc := HandlerCtx(ctx) + for i := range 50 { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, _ = fmt.Fprintf(hc.Stderr, "%s:%02d\n", args[0], i) + } + return nil + } + + err = r.Run(context.Background(), parseScript(t, "leftcmd | rightcmd")) + require.NoError(t, err) + assert.Empty(t, stdout.String()) + + for _, line := range strings.Split(strings.TrimRight(stderr.String(), "\n"), "\n") { + if line == "" { + continue + } + assert.Regexp(t, `^(leftcmd|rightcmd):[0-9]{2}$`, line) + } +} + +func TestVulnHuntSubsystemSignalHandling_SimpleCommandPanicReturnsInternalError(t *testing.T) { + var stderr bytes.Buffer + r, err := New( + StdIO(nil, io.Discard, &stderr), + allowAllCommandsOpt(), + MaxExecutionTime(time.Second), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + r.execHandler = func(context.Context, []string) error { + panic("controlled panic") + } + + err = r.Run(context.Background(), parseScript(t, "paniccmd")) + require.EqualError(t, err, "internal error") + assert.Contains(t, stderr.String(), "rshell: internal panic: controlled panic") +} From e2e7bb42fa872d5c44c764dbe4f636d61a12bb68 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 21:32:46 +0200 Subject: [PATCH 50/73] test: add uname vuln hunt regressions --- .../tests/uname/uname_vuln_hunt_linux_test.go | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 builtins/tests/uname/uname_vuln_hunt_linux_test.go diff --git a/builtins/tests/uname/uname_vuln_hunt_linux_test.go b/builtins/tests/uname/uname_vuln_hunt_linux_test.go new file mode 100644 index 00000000..432c94f3 --- /dev/null +++ b/builtins/tests/uname/uname_vuln_hunt_linux_test.go @@ -0,0 +1,178 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build linux + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// uname. + +package uname_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinUname_UnsupportedFlagSurfaceRejected(t *testing.T) { + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + + for _, script := range []string{ + "uname --processor", + "uname --operating-system", + "uname --version", + "uname --kernel-name=Linux", + "uname -s=Linux", + "uname -- -a", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := cmdRun(t, script, dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "uname:") + }) + } +} + +func TestVulnHuntBuiltinUname_HelpDoesNotReadProcOrValidateTrailingJunk(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := cmdRun(t, "uname --help foo --kernel-name=/etc/passwd", dir) + + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: uname") + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinUname_DefaultAndOrderStableAcrossRepeatedCommands(t *testing.T) { + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + + stdout, stderr, code := cmdRun(t, "uname -a\nuname\nuname -mrvns\n", dir) + + assert.Equal(t, 0, code, "stderr: %s", stderr) + assert.Equal(t, + "Linux testhost 5.15.0-test #1 SMP Test x86_64\n"+ + "Linux\n"+ + "Linux testhost 5.15.0-test #1 SMP Test x86_64\n", + stdout, + ) +} + +func TestVulnHuntBuiltinUname_ProcPathScriptAssignmentCannotRedirect(t *testing.T) { + root := t.TempDir() + configuredProc := filepath.Join(root, "configured") + evilProc := filepath.Join(root, "evil") + writeFakeProc(t, configuredProc, map[string]string{"ostype": "Linux"}) + writeFakeProc(t, evilProc, map[string]string{"ostype": "EVIL"}) + + stdout, stderr, code := runScript(t, + "ProcPath="+evilProc+" uname -s\n", + root, + interp.ProcPath(configuredProc), + ) + + assert.Equal(t, 0, code, "stderr: %s", stderr) + assert.Equal(t, "Linux\n", stdout) +} + +func TestVulnHuntBuiltinUname_AllowedPathsDoNotReachProcPath(t *testing.T) { + procPath := t.TempDir() + allowed := t.TempDir() + writeFakeProc(t, procPath, defaultFakeProc()) + + stdout, stderr, code := runScript(t, + "uname -a", + allowed, + interp.ProcPath(procPath), + interp.AllowedPaths([]string{allowed}), + ) + + assert.Equal(t, 0, code, "stderr: %s", stderr) + assert.Equal(t, "Linux testhost 5.15.0-test #1 SMP Test x86_64\n", stdout) +} + +func TestVulnHuntBuiltinUname_ProcPathTraversalRejected(t *testing.T) { + root := t.TempDir() + secretProc := filepath.Join(root, "secretproc") + writeFakeProc(t, secretProc, map[string]string{"ostype": "SECRET"}) + traversalProc := root + "/proc/../secretproc" + + stdout, stderr, code := runScript(t, + "uname -s", + root, + interp.ProcPath(traversalProc), + ) + + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "unsafe procPath") + assert.NotContains(t, stderr, "SECRET") +} + +func TestVulnHuntBuiltinUname_OverlongProcValueIsCapped(t *testing.T) { + dir := t.TempDir() + kernelDir := filepath.Join(dir, "sys", "kernel") + require.NoError(t, os.MkdirAll(kernelDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(kernelDir, "ostype"), []byte(strings.Repeat("A", 1<<20)), 0o644)) + + stdout, stderr, code := cmdRun(t, "uname -s", dir) + + assert.Equal(t, 0, code, "stderr: %s", stderr) + assert.Len(t, stdout, 4097) + assert.True(t, strings.HasPrefix(stdout, strings.Repeat("A", 4096))) + assert.Equal(t, byte('\n'), stdout[len(stdout)-1]) +} + +func TestVulnHuntBuiltinUname_FifoProcEntryRejectedPromptly(t *testing.T) { + dir := t.TempDir() + kernelDir := filepath.Join(dir, "sys", "kernel") + require.NoError(t, os.MkdirAll(kernelDir, 0o755)) + require.NoError(t, syscall.Mkfifo(filepath.Join(kernelDir, "ostype"), 0o600)) + + start := time.Now() + stdout, stderr, code := cmdRun(t, "uname -s", dir) + + assert.Equal(t, 1, code) + assert.Less(t, time.Since(start), time.Second) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "not a regular file") +} + +func TestVulnHuntBuiltinUname_CharDeviceProcEntryIsCapped(t *testing.T) { + dir := t.TempDir() + kernelDir := filepath.Join(dir, "sys", "kernel") + require.NoError(t, os.MkdirAll(kernelDir, 0o755)) + require.NoError(t, os.Symlink("/dev/zero", filepath.Join(kernelDir, "ostype"))) + + stdout, stderr, code := cmdRun(t, "uname -s", dir) + + assert.Equal(t, 0, code, "stderr: %s", stderr) + assert.Empty(t, stderr) + assert.Len(t, stdout, 4097) + assert.Equal(t, byte('\n'), stdout[len(stdout)-1]) +} + +func TestVulnHuntBuiltinUname_PreCanceledContextHasNoPartialOutput(t *testing.T) { + dir := t.TempDir() + writeFakeProc(t, dir, defaultFakeProc()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + stdout, _, code := runScriptCtx(ctx, t, "uname -a", dir, interp.ProcPath(dir)) + + assert.NotEqual(t, 0, code) + assert.Empty(t, stdout) +} From 15fb3b099ca86996e23a641d3f54283c2ac23751 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 21:40:22 +0200 Subject: [PATCH 51/73] test: add allowed commands vuln hunt regressions --- interp/allowed_commands_vuln_hunt_test.go | 212 ++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 interp/allowed_commands_vuln_hunt_test.go diff --git a/interp/allowed_commands_vuln_hunt_test.go b/interp/allowed_commands_vuln_hunt_test.go new file mode 100644 index 00000000..e56d9aa9 --- /dev/null +++ b/interp/allowed_commands_vuln_hunt_test.go @@ -0,0 +1,212 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// allowed_commands. + +package interp_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func runAllowedCommandsVulnHunt(t *testing.T, ctx context.Context, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + prog, err := interp.ParseScript(script, "allowed_commands_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]interp.RunnerOption{interp.StdIO(nil, &stdout, &stderr)}, opts...) + r, err := interp.New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(ctx, prog) + code := 0 + if err != nil { + var status interp.ExitStatus + if errors.As(err, &status) { + code = int(status) + } else if ctx.Err() != nil { + code = 124 + } else { + t.Fatalf("unexpected Run error: %v", err) + } + } + return stdout.String(), stderr.String(), code +} + +func TestVulnHuntShellFeatureAllowedCommands_ExpandedMetacharactersAreNotReparsed(t *testing.T) { + stdout, stderr, code := runAllowedCommandsVulnHunt(t, + context.Background(), + "CMD='cat; echo PWNED'\n$CMD\necho after\n", + "", + interp.AllowedCommands([]string{"rshell:echo"}), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "after\n", stdout) + assert.Contains(t, stderr, "command not allowed") + assert.NotContains(t, stdout, "PWNED") +} + +func TestVulnHuntShellFeatureAllowedCommands_InlinePolicyAssignmentCannotAllowCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("SECRET"), 0o644)) + + stdout, stderr, code := runAllowedCommandsVulnHunt(t, + context.Background(), + "ALLOWED_COMMANDS=rshell:cat cat secret.txt\necho after\n", + dir, + interp.AllowedPaths([]string{dir}), + interp.AllowedCommands([]string{"rshell:echo"}), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "after\n", stdout) + assert.Contains(t, stderr, "rshell: cat: command not allowed") + assert.NotContains(t, stdout, "SECRET") +} + +func TestVulnHuntShellFeatureAllowedCommands_SubshellPolicyAssignmentCannotAllowCommand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("SECRET"), 0o644)) + + stdout, stderr, code := runAllowedCommandsVulnHunt(t, + context.Background(), + "( ALLOWED_COMMANDS=rshell:cat; cat secret.txt; echo inside )\necho after\n", + dir, + interp.AllowedPaths([]string{dir}), + interp.AllowedCommands([]string{"rshell:echo"}), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "inside\nafter\n", stdout) + assert.Contains(t, stderr, "rshell: cat: command not allowed") + assert.NotContains(t, stdout, "SECRET") +} + +func TestVulnHuntShellFeatureAllowedCommands_HelpHintRequiresHelpAllowed(t *testing.T) { + _, stderr, code := runAllowedCommandsVulnHunt(t, + context.Background(), + "cat /dev/null\n", + "", + interp.AllowedCommands([]string{"rshell:echo"}), + ) + assert.Equal(t, 127, code) + assert.Contains(t, stderr, "rshell: cat: command not allowed") + assert.NotContains(t, stderr, "Run 'help'") + + _, stderr, code = runAllowedCommandsVulnHunt(t, + context.Background(), + "cat /dev/null\n", + "", + interp.AllowedCommands([]string{"rshell:echo", "rshell:help"}), + ) + assert.Equal(t, 127, code) + assert.Contains(t, stderr, "Run 'help' to see allowed commands.") +} + +func TestVulnHuntShellFeatureAllowedCommands_FindExecReplacementRechecked(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "cat"), []byte("placeholder"), 0o644)) + + stdout, stderr, code := runAllowedCommandsVulnHunt(t, + context.Background(), + "find . -maxdepth 1 -name cat -exec {} \\;\necho after\n", + dir, + interp.AllowedPaths([]string{dir}), + interp.AllowedCommands([]string{"rshell:find", "rshell:echo"}), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "after\n", stdout) + assert.Contains(t, stderr, "command not allowed") +} + +func TestVulnHuntShellFeatureAllowedCommands_CatShortcutDeniedBeforeFileOpen(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("SECRET"), 0o644)) + + stdout, stderr, code := runAllowedCommandsVulnHunt(t, + context.Background(), + "x=$( Date: Wed, 20 May 2026 21:55:57 +0200 Subject: [PATCH 52/73] test: add sort vuln hunt regressions --- builtins/sort/sort_vuln_hunt_test.go | 121 ++++++++++++++++++++++ builtins/sort/sort_vuln_hunt_unix_test.go | 26 +++++ 2 files changed, 147 insertions(+) create mode 100644 builtins/sort/sort_vuln_hunt_test.go create mode 100644 builtins/sort/sort_vuln_hunt_unix_test.go diff --git a/builtins/sort/sort_vuln_hunt_test.go b/builtins/sort/sort_vuln_hunt_test.go new file mode 100644 index 00000000..66c3eccf --- /dev/null +++ b/builtins/sort/sort_vuln_hunt_test.go @@ -0,0 +1,121 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: sort) + +package sort_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rsort "github.com/DataDog/rshell/builtins/sort" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinSort_CumulativeByteCapAcrossFiles(t *testing.T) { + dir := t.TempDir() + line := []byte(strings.Repeat("a", 1000) + "\n") + perFileLines := rsort.MaxTotalBytes/2000 + 8 + content := bytes.Repeat(line, perFileLines) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), content, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), content, 0o644)) + + mustNotHang(t, func() { + stdout, stderr, code := sortRun(t, "sort a.txt b.txt", dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "sort:") + assert.Contains(t, stderr, "exceeds maximum") + assert.Contains(t, stderr, "5 MiB") + }) +} + +func TestVulnHuntBuiltinSort_KeyNumericOverflowRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b 2\na 1\n") + + for _, tc := range []struct { + name string + script string + wantStderr string + }{ + { + name: "field overflow", + script: "sort -k 999999999999999999999999999999 f.txt", + wantStderr: "invalid field number in key", + }, + { + name: "character overflow", + script: "sort -k 1.999999999999999999999999999999 f.txt", + wantStderr: "invalid character position in key", + }, + } { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, code := sortRun(t, tc.script, dir) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "sort:") + assert.Contains(t, stderr, tc.wantStderr) + }) + } +} + +func TestVulnHuntBuiltinSort_UnsafeFlagsHelpShortCircuitNoSideEffects(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\n") + + for _, script := range []string{ + "sort --help --output=out.txt f.txt", + "sort --help --temporary-directory=. f.txt", + "sort --help --compress-program=sh f.txt", + } { + stdout, stderr, code := sortRun(t, script, dir) + assert.Equal(t, 0, code, script) + assert.Contains(t, stdout, "Usage: sort") + assert.Empty(t, stderr) + } + + _, err := os.Stat(filepath.Join(dir, "out.txt")) + assert.True(t, os.IsNotExist(err)) +} + +func TestVulnHuntBuiltinSort_TempDirShortFlagRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\n") + + stdout, stderr, code := sortRun(t, "sort -T . f.txt", dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "sort:") +} + +func TestVulnHuntBuiltinSort_CheckSilentSuppressesRawDisorderLine(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "z\nA\rFORGED_SORT_ROW=1\n") + + stdout, stderr, code := sortRun(t, "sort -C f.txt", dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinSort_PreCanceledContextProducesNoOutput(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "f.txt", "b\na\n") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + stdout, stderr, _ := runScriptCtx(ctx, t, "sort f.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} diff --git a/builtins/sort/sort_vuln_hunt_unix_test.go b/builtins/sort/sort_vuln_hunt_unix_test.go new file mode 100644 index 00000000..c453fc9f --- /dev/null +++ b/builtins/sort/sort_vuln_hunt_unix_test.go @@ -0,0 +1,26 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package sort_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVulnHuntBuiltinSort_DevZeroHitsLineCap(t *testing.T) { + dir := t.TempDir() + + mustNotHang(t, func() { + stdout, stderr, code := sortRun(t, "sort /dev/zero", dir, "/dev") + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "sort:") + assert.Contains(t, stderr, "token too long") + }) +} From e3e5d77d9483359ffa2b2bed6ebaa4d5dd8c3fdf Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 22:02:49 +0200 Subject: [PATCH 53/73] test: add while clause vuln hunt regressions --- .../while_clause_cyber3_vuln_hunt_test.go | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 interp/tests/while_clause_cyber3_vuln_hunt_test.go diff --git a/interp/tests/while_clause_cyber3_vuln_hunt_test.go b/interp/tests/while_clause_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..2f2dec92 --- /dev/null +++ b/interp/tests/while_clause_cyber3_vuln_hunt_test.go @@ -0,0 +1,88 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: while_clause) + +package tests_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntWhileClause_RunnerReuseClearsLoopControl(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := interp.New( + interp.StdIO(nil, &stdout, &stderr), + interpoption.AllowAllCommands().(interp.RunnerOption), + ) + require.NoError(t, err) + defer r.Close() + + parser := syntax.NewParser() + first, err := parser.Parse(bytes.NewReader([]byte("while true; do break; done\n")), "") + require.NoError(t, err) + require.NoError(t, r.Run(context.Background(), first)) + + second, err := parser.Parse(bytes.NewReader([]byte("echo after\n")), "") + require.NoError(t, err) + require.NoError(t, r.Run(context.Background(), second)) + + assert.Equal(t, "after\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntWhileClause_FailedInputRedirectRestoresStdin(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("ok\n"), 0o644)) + + stdout, stderr, code := testutil.RunScript(t, `while read line; do echo bad; done < missing.txt +cat input.txt +`, dir, interp.AllowedPaths([]string{dir})) + + assert.Equal(t, 0, code) + assert.Equal(t, "ok\n", stdout) + assert.Contains(t, stderr, "missing.txt") + assert.NotContains(t, stdout, "bad") +} + +func TestVulnHuntWhileClause_ConditionCmdSubstDeniedBeforeBody(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "allowed"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "secret"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "secret", "forbidden.txt"), []byte("secret\n"), 0o644)) + + stdout, stderr, code := testutil.RunScript(t, `while [ -n "$(cat secret/forbidden.txt)" ]; do echo body; break; done +echo done +`, root, interp.AllowedPaths([]string{filepath.Join(root, "allowed")})) + + assert.Equal(t, 0, code) + assert.Equal(t, "done\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "body") +} + +func TestVulnHuntWhileClause_ReadonlyConditionAssignmentBlocked(t *testing.T) { + stdout, stderr, code := whileRun(t, `readonly i +while i=a; do echo body; break; done +echo "i=$i" +`) + + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "readonly") + assert.NotContains(t, stdout, "body") +} From 6911a18a2b358c69ba7a526c8f75439ea31f2341 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 22:09:55 +0200 Subject: [PATCH 54/73] test: add executor context cancellation regression --- ...text_cancellation_cyber3_vuln_hunt_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 interp/executor_context_cancellation_cyber3_vuln_hunt_test.go diff --git a/interp/executor_context_cancellation_cyber3_vuln_hunt_test.go b/interp/executor_context_cancellation_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..dd68148c --- /dev/null +++ b/interp/executor_context_cancellation_cyber3_vuln_hunt_test.go @@ -0,0 +1,44 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: executor-context-cancellation) + +package interp + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntSubsystemExecutorContextCancellation_RunAfterTimeoutGetsFreshState(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New( + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + + r.execHandler = func(ctx context.Context, _ []string) error { + <-ctx.Done() + return ctx.Err() + } + err = r.Run(context.Background(), parseScript(t, "slowcmd")) + require.ErrorIs(t, err, context.DeadlineExceeded) + + r.execHandler = func(context.Context, []string) error { return nil } + err = r.Run(context.Background(), parseScript(t, "echo after")) + require.NoError(t, err) + + assert.Equal(t, "after\n", stdout.String()) + assert.Empty(t, stderr.String()) +} From 424c8fd7e55236dbf0fb13702a7519ba1b087b90 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 22:20:52 +0200 Subject: [PATCH 55/73] test: add tr vuln hunt regressions --- builtins/tr/tr_vuln_hunt_test.go | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 builtins/tr/tr_vuln_hunt_test.go diff --git a/builtins/tr/tr_vuln_hunt_test.go b/builtins/tr/tr_vuln_hunt_test.go new file mode 100644 index 00000000..5f29568d --- /dev/null +++ b/builtins/tr/tr_vuln_hunt_test.go @@ -0,0 +1,88 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: tr) + +package tr_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinTrFlagDrivenExploit_DangerousFlagsRejected(t *testing.T) { + for _, script := range []string{ + "tr --output=/tmp/out a b", + "tr --reference=/etc/passwd a b", + "tr -w a b", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := runScript(t, script, t.TempDir()) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "tr:") + assert.Contains(t, stderr, "Try 'tr --help' for more information.") + }) + } +} + +func TestVulnHuntBuiltinTrFlagDrivenExploit_FlagShapedSetsStayData(t *testing.T) { + stdout, stderr, code := trRun(t, "-d-d", "-- -d XY") + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "XYXY", stdout) + + stdout, stderr, code = trRun(t, "az", "a -Z") + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "-z", stdout) +} + +func TestVulnHuntBuiltinTrIntegerOverflow_RepeatCountsClampedOrRejected(t *testing.T) { + stdout, stderr, code := trRun(t, "a", "a '[b*9223372036854775807]'") + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "b", stdout) + + _, stderr, code = trRun(t, "a", "a '[b*9223372036854775808]'") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid repeat count '9223372036854775808'") + + _, stderr, code = trRun(t, "a", "a '[b*-0]'") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid repeat count '-0'") +} + +func TestVulnHuntBuiltinTrIntegerOverflow_FillRepeatTailDoesNotUnderflow(t *testing.T) { + stdout, stderr, code := trRun(t, "a", "a '[x*]YZ'") + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "Y", stdout) +} + +func TestVulnHuntBuiltinTrResourceExhaustion_LargeFiniteInputStreams(t *testing.T) { + dir := t.TempDir() + input := strings.Repeat("abcXYZ\n", 80_000) + writeFile(t, dir, "in.txt", input) + + stdout, stderr, code := runScript(t, "cat in.txt | tr a-z A-Z", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, strings.ToUpper(input), stdout) +} + +func TestVulnHuntBuiltinTrDeclaredVsImplemented_BinaryBytesRemainBytes(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "in.txt", string([]byte{0x00, 'a', '\r', '\n', 0xff, 'a'})) + + stdout, stderr, code := runScript(t, `tr '\000a\377' 'XyZ' < in.txt`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, string([]byte{'X', 'y', '\r', '\n', 'Z', 'y'}), stdout) +} From 1f5096d69bb701f01530be30f3a7a22a0d5d1253 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 22:26:22 +0200 Subject: [PATCH 56/73] test: add pwd vuln hunt regressions --- builtins/pwd/pwd_cyber3_vuln_hunt_test.go | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 builtins/pwd/pwd_cyber3_vuln_hunt_test.go diff --git a/builtins/pwd/pwd_cyber3_vuln_hunt_test.go b/builtins/pwd/pwd_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..debf6b64 --- /dev/null +++ b/builtins/pwd/pwd_cyber3_vuln_hunt_test.go @@ -0,0 +1,53 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt 2026-05-20-gpt-5.5-cyber-3 (target: pwd) + +package pwd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinPwdFlagDrivenExploit_ModeFlagValuesRejected(t *testing.T) { + dir := canonicalTempDir(t) + for _, script := range []string{ + "pwd --physical=false", + "pwd --physical=true", + "pwd --logical=true", + "pwd --logical=TRUE", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code := pwdRun(t, script, dir) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "pwd:") + assert.Contains(t, stderr, "doesn't allow an argument") + }) + } +} + +func TestVulnHuntBuiltinPwdDeclaredVsImplemented_HelpDoesNotPrintCwd(t *testing.T) { + if filepath.Separator == '\\' { + t.Skip("newline path component is Unix-specific") + } + root := canonicalTempDir(t) + weird := filepath.Join(root, "cwd\nFORGED_PWD_HELP_ROW=1") + require.NoError(t, os.Mkdir(weird, 0755)) + + stdout, stderr, code := testutil.RunScript(t, "pwd --help ignored", weird, interp.AllowedPaths([]string{root})) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "Usage: pwd") + assert.NotContains(t, stdout, "FORGED_PWD_HELP_ROW") +} From de1579636ba2b0b2fbf86830c1c7c3701178616c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 22:39:44 +0200 Subject: [PATCH 57/73] test: add globbing vuln hunt regressions --- interp/globbing_cyber3_vuln_hunt_test.go | 227 +++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 interp/globbing_cyber3_vuln_hunt_test.go diff --git a/interp/globbing_cyber3_vuln_hunt_test.go b/interp/globbing_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..0b3c09c3 --- /dev/null +++ b/interp/globbing_cyber3_vuln_hunt_test.go @@ -0,0 +1,227 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: globbing (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" + + "github.com/DataDog/rshell/allowedpaths" +) + +func runGlobbingCyber3Script(t *testing.T, script, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + prog := parseScript(t, script) + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), prog) + code := 0 + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureGlobbing_ExpansionChain_FilenamesAreData(t *testing.T) { + dir := t.TempDir() + names := []string{ + "$(echo PWNED).txt", + "redir>out.txt", + "semi;echo PWNED.txt", + "space name.txt", + } + for _, name := range names { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644)) + } + + stdout, stderr, code, err := runGlobbingCyber3Script(t, "echo *\n", dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "$(echo PWNED).txt redir>out.txt semi;echo PWNED.txt space name.txt\n", stdout) + assert.Empty(t, stderr) + _, statErr := os.Stat(filepath.Join(dir, "out.txt")) + assert.True(t, os.IsNotExist(statErr), "glob result containing > must not become a redirect") +} + +func TestVulnHuntShellFeatureGlobbing_ExpansionChain_OutsidePatternStaysSandboxed(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.MkdirAll(allowed, 0o755)) + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("SECRET\n"), 0o644)) + + stdout, stderr, code, err := runGlobbingCyber3Script(t, + "PATTERN='../outside/*'\necho $PATTERN\necho after\n", + allowed, + AllowedPaths([]string{allowed}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "../outside/*") + assert.Contains(t, stdout, "after\n") + assert.NotContains(t, stdout+stderr, "SECRET") +} + +func TestVulnHuntShellFeatureGlobbing_ParserConfusion_ExtglobRejectedAndSlashPatternsLiteral(t *testing.T) { + stdout, stderr, code, err := runGlobbingCyber3Script(t, "echo @(safe)\necho after\n", "") + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "extended globbing is not supported") + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "foo", "dir"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "foo", "dir", "file"), []byte("x"), 0o644)) + stdout, stderr, code, err = runGlobbingCyber3Script(t, + "echo foo[/]dir[/]file\necho foo?dir?file\n", + dir, + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "foo[/]dir[/]file\nfoo?dir?file\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureGlobbing_RedirectionChain_DynamicOrGlobRedirectsDoNotWidenPolicy(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("SECRET\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dev"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dev", "null"), []byte("not a device"), 0o644)) + + for _, script := range []string{ + "TARGET=/dev/null\necho hi > $TARGET\necho after\n", + "echo hi > dev/nul*\necho after\n", + } { + stdout, stderr, code, err := runGlobbingCyber3Script(t, script, dir, AllowedPaths([]string{dir})) + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "file redirection is not supported") + } + + stdout, stderr, code, err := runGlobbingCyber3Script(t, + "cat < *.txt\necho status=$?\n", + dir, + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\n", stdout) + assert.Contains(t, stderr, "*.txt") + assert.NotContains(t, stdout+stderr, "SECRET") +} + +func TestVulnHuntShellFeatureGlobbing_CompositionAttack_ForLoopFilenamesRemainData(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"$(echo hacked).txt", "semi;echo hacked.txt", "space name.txt"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644)) + } + + stdout, stderr, code, err := runGlobbingCyber3Script(t, + "for f in *; do echo \"[$f]\"; done\n", + dir, + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "[$(echo hacked).txt]\n[semi;echo hacked.txt]\n[space name.txt]\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureGlobbing_ReadonlyBypass_ForLoopVariableRespectsReadonly(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0o644)) + + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt(), AllowedPaths([]string{dir})) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + r.Reset() + require.NoError(t, r.writeEnv.Set("RO", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + + err = r.Run(context.Background(), parseScript(t, "for RO in *; do echo \"loop=$RO\"; done\necho after=$RO\n")) + + require.NoError(t, err) + assert.Contains(t, stderr.String(), "readonly variable") + assert.NotContains(t, stdout.String(), "loop=a.txt") + assert.NotContains(t, stdout.String(), "loop=b.txt") + assert.Contains(t, stdout.String(), "after=original") +} + +func TestVulnHuntShellFeatureGlobbing_SignalContext_ForGlobLoopCancels(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644)) + + var stdout, stderr bytes.Buffer + r, err := New( + StdIO(nil, &stdout, &stderr), + allowAllCommandsOpt(), + AllowedPaths([]string{dir}), + MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Dir = dir + + start := time.Now() + err = r.Run(context.Background(), parseScript(t, "while true; do for f in *.txt; do :; done; done\n")) + + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 2*time.Second) + assert.Empty(t, stdout.String()) +} + +func TestVulnHuntShellFeatureGlobbing_DeclaredVsImplemented_DirectoryEntryCapRejectsBeforeMatch(t *testing.T) { + dir := t.TempDir() + for i := 0; i < allowedpaths.MaxGlobEntries+1; i++ { + require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%05d", i)), []byte("x"), 0o644)) + } + + stdout, stderr, code, err := runGlobbingCyber3Script(t, "echo *\n", dir, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "directory has too many entries") +} From 8d29c55d14b01b8c537da830f2d95b8e4fd37a10 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 22:57:08 +0200 Subject: [PATCH 58/73] test: add find vuln hunt regressions --- builtins/find/find_cyber3_vuln_hunt_test.go | 93 +++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 builtins/find/find_cyber3_vuln_hunt_test.go diff --git a/builtins/find/find_cyber3_vuln_hunt_test.go b/builtins/find/find_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..4bc65e7d --- /dev/null +++ b/builtins/find/find_cyber3_vuln_hunt_test.go @@ -0,0 +1,93 @@ +package find + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnHuntBuiltinFindBlockedPredicatesBehindBooleanParsing(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"or delete", []string{"-true", "-o", "-delete"}}, + {"not ok", []string{"!", "-ok", "echo", ";"}}, + {"group fprintf", []string{"(", "-true", ")", "-fprintf", "/tmp/out", "%p"}}, + {"and regex", []string{"-name", "*.txt", "-a", "-regex", ".*"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseExpression(tt.args) + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked") + }) + } +} + +func TestVulnHuntBuiltinFindExecCommandNameSubstitutionStillUsesPolicy(t *testing.T) { + tests := []struct { + name string + execKind exprKind + relPath string + print string + wantCmd string + }{ + { + name: "execdir basename replacement", + execKind: exprExecDir, + relPath: "echo", + wantCmd: "./echo", + }, + { + name: "exec full path replacement", + execKind: exprExec, + relPath: "dir/echo", + print: "dir/echo", + wantCmd: "dir/echo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + runCalled := false + callCtx := newPentestCallCtx(&stdout, &stderr) + callCtx.CommandAllowed = func(name string) bool { + return name == "echo" + } + callCtx.RunCommand = func(_ context.Context, _ string, _ string, _ []string) (uint8, error) { + runCalled = true + return 0, nil + } + + ec := &evalContext{ + callCtx: callCtx, + ctx: context.Background(), + relPath: tt.relPath, + info: &mockFileInfo{}, + } + if tt.print != "" { + ec.printPath = tt.print + } + + e := &expr{kind: tt.execKind, execCmd: "{}", execArgs: []string{"arg"}} + var result evalResult + if tt.execKind == exprExecDir { + result = evalExecDir(ec, e) + } else { + result = evalExec(ec, e) + } + + assert.False(t, result.matched) + assert.True(t, ec.failed) + assert.False(t, runCalled) + assert.Contains(t, stderr.String(), tt.wantCmd) + assert.Contains(t, stderr.String(), "not allowed") + }) + } +} From 72b0d860955eeca7fb2b302046db62f352086897 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 23:08:46 +0200 Subject: [PATCH 59/73] test: add wc timeout regression --- .../tests/wc/wc_cyber3_vuln_hunt_unix_test.go | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 builtins/tests/wc/wc_cyber3_vuln_hunt_unix_test.go diff --git a/builtins/tests/wc/wc_cyber3_vuln_hunt_unix_test.go b/builtins/tests/wc/wc_cyber3_vuln_hunt_unix_test.go new file mode 100644 index 00000000..cf51132b --- /dev/null +++ b/builtins/tests/wc/wc_cyber3_vuln_hunt_unix_test.go @@ -0,0 +1,39 @@ +//go:build unix + +package wc_test + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinWcDevZeroRespectsMaxExecutionTime(t *testing.T) { + prog, err := syntax.NewParser().Parse(strings.NewReader("wc -c /dev/zero\n"), "") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + r, err := interp.New( + interp.StdIO(nil, &stdout, &stderr), + interp.AllowedCommands([]string{"rshell:wc"}), + interp.AllowedPaths([]string{"/dev"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + start := time.Now() + err = r.Run(context.Background(), prog) + elapsed := time.Since(start) + + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, elapsed, 2*time.Second) +} From a2fcfbd90a965be74dd8442f00d742dda337fd21 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 23:18:14 +0200 Subject: [PATCH 60/73] test: add false vuln hunt tripwires --- builtins/false/false_cyber3_vuln_hunt_test.go | 69 +++++++++++++++ .../false/false_cyber3_vuln_hunt_test.go | 86 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 builtins/false/false_cyber3_vuln_hunt_test.go create mode 100644 builtins/tests/false/false_cyber3_vuln_hunt_test.go diff --git a/builtins/false/false_cyber3_vuln_hunt_test.go b/builtins/false/false_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..5f9b1dc1 --- /dev/null +++ b/builtins/false/false_cyber3_vuln_hunt_test.go @@ -0,0 +1,69 @@ +package falsecmd + +import ( + "context" + "strings" + "sync" + "testing" +) + +func TestVulnHuntBuiltinFalsePureStatusIgnoresAllArguments(t *testing.T) { + cases := [][]string{ + nil, + {}, + {"--help"}, + {"-h"}, + {"--unknown"}, + {"--"}, + {"--", "--help"}, + {"name\nFORGED_FALSE_ROW=1"}, + {"$(echo should-not-run)", ";", "echo", "pwned"}, + {strings.Repeat("A", 1<<20)}, + } + + for _, args := range cases { + result := run(context.Background(), nil, args) + if result.Code != 1 { + t.Fatalf("false(%q) Code = %d, want 1", args, result.Code) + } + if result.Exiting || result.BreakN != 0 || result.ContinueN != 0 { + t.Fatalf("false(%q) produced control-flow result: %+v", args, result) + } + } +} + +func TestVulnHuntBuiltinFalseCanceledContextStillHasNoIOSurface(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := run(ctx, nil, []string{"--help", "ignored"}) + if result.Code != 1 { + t.Fatalf("false with canceled context Code = %d, want 1", result.Code) + } + if result.Exiting || result.BreakN != 0 || result.ContinueN != 0 { + t.Fatalf("false with canceled context produced control-flow result: %+v", result) + } +} + +func TestVulnHuntBuiltinFalseConcurrentRunsDoNotShareState(t *testing.T) { + const workers = 64 + + var wg sync.WaitGroup + errs := make(chan string, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + result := run(context.Background(), nil, []string{"--help", "ignored"}) + if result.Code != 1 || result.Exiting || result.BreakN != 0 || result.ContinueN != 0 { + errs <- "unexpected result" + } + }() + } + wg.Wait() + close(errs) + + for err := range errs { + t.Fatal(err) + } +} diff --git a/builtins/tests/false/false_cyber3_vuln_hunt_test.go b/builtins/tests/false/false_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..c8496ee8 --- /dev/null +++ b/builtins/tests/false/false_cyber3_vuln_hunt_test.go @@ -0,0 +1,86 @@ +package false_test + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +func runScript(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + + prog, err := syntax.NewParser().Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + runner, err := interp.New(append([]interp.RunnerOption{ + interp.StdIO(nil, &stdout, &stderr), + }, opts...)...) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + err = runner.Run(context.Background(), prog) + code := 0 + if err != nil { + var exit interp.ExitStatus + if errors.As(err, &exit) { + code = int(exit) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + + return stdout.String(), stderr.String(), code +} + +func TestVulnHuntBuiltinFalseDoesNotReadBlockedStdin(t *testing.T) { + stdin, writer, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + t.Cleanup(func() { _ = writer.Close() }) + + prog, err := syntax.NewParser().Parse(strings.NewReader("false\n"), "") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(stdin, &stdout, &stderr), + interp.AllowedCommands([]string{"rshell:false"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + start := time.Now() + err = runner.Run(context.Background(), prog) + elapsed := time.Since(start) + + var exit interp.ExitStatus + require.ErrorAs(t, err, &exit) + assert.Equal(t, interp.ExitStatus(1), exit) + assert.Less(t, elapsed, 500*time.Millisecond) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntBuiltinFalseHelpMetadataDoesNotExecuteFalse(t *testing.T) { + stdout, stderr, code := runScript(t, "false --help\necho false_status=$?\nhelp false\necho help_status=$?\n", + interp.AllowedCommands([]string{"rshell:false", "rshell:help", "rshell:echo"})) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "false_status=1\n") + assert.Contains(t, stdout, "false: false\n") + assert.Contains(t, stdout, "help_status=0\n") + assert.NotContains(t, stdout, "Usage:") +} From cf2750ea39fd89c8ef75b34fd583b0d5dce46668 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 23:29:54 +0200 Subject: [PATCH 61/73] test: add printf vuln hunt tripwires --- .../printf/printf_cyber3_vuln_hunt_test.go | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 builtins/printf/printf_cyber3_vuln_hunt_test.go diff --git a/builtins/printf/printf_cyber3_vuln_hunt_test.go b/builtins/printf/printf_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..28f4592f --- /dev/null +++ b/builtins/printf/printf_cyber3_vuln_hunt_test.go @@ -0,0 +1,98 @@ +package printf_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntBuiltinPrintfLengthModifiersDoNotSmuggleUnsupportedSpecifiers(t *testing.T) { + for _, script := range []string{ + `printf "%ln" foo`, + `printf "%lln" foo`, + `printf "%zn" foo`, + `printf "%hq" foo`, + `printf "%la" 1.0`, + `printf "%lA" 1.0`, + } { + stdout, stderr, code := cmdRun(t, script) + assert.Equal(t, 1, code, script) + assert.Empty(t, stdout, script) + assert.Contains(t, stderr, "printf:", script) + } +} + +func TestVulnHuntBuiltinPrintfDoesNotReadBlockedStdin(t *testing.T) { + stdin, writer, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + t.Cleanup(func() { _ = writer.Close() }) + + prog, err := syntax.NewParser().Parse(strings.NewReader(`printf "%s\n" ok`), "") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(stdin, &stdout, &stderr), + interp.AllowedCommands([]string{"rshell:printf"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + start := time.Now() + err = runner.Run(context.Background(), prog) + elapsed := time.Since(start) + + require.NoError(t, err) + assert.Less(t, elapsed, 500*time.Millisecond) + assert.Equal(t, "ok\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntBuiltinPrintfInputRedirectSandboxedBeforeNoStdinCommand(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.MkdirAll(allowed, 0o755)) + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644)) + + stdout, stderr, code := runScript(t, "printf ok < ../outside/secret.txt\n", allowed, + interp.AllowedCommands([]string{"rshell:printf"}), + interp.AllowedPaths([]string{allowed})) + + assert.NotEqual(t, 0, code) + assert.Empty(t, stdout) + assert.NotEmpty(t, stderr) +} + +func TestVulnHuntBuiltinPrintfHelpDoesNotApplyTrailingDangerousArgs(t *testing.T) { + stdout, stderr, code := runScript(t, `printf --help -v PWNED "%n"; echo status=$?; echo PWNED=$PWNED`, "", + interp.AllowedCommands([]string{"rshell:printf", "rshell:echo"})) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "printf: usage: printf") + assert.Contains(t, stdout, "status=2\n") + assert.Contains(t, stdout, "PWNED=\n") +} + +func TestVulnHuntBuiltinPrintfExpansionRemainsData(t *testing.T) { + stdout, stderr, code := runScript(t, `PAYLOAD='; echo PWNED'; printf '[%s]\n' "$PAYLOAD"`, "", + interp.AllowedCommands([]string{"rshell:printf"})) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "[; echo PWNED]\n", stdout) +} From 6dbf0f0583e5ca062dc4495ca7e79a8420822a80 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Wed, 20 May 2026 23:46:11 +0200 Subject: [PATCH 62/73] test: add builtin import allowlist tripwires --- ..._import_allowlist_cyber3_vuln_hunt_test.go | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 analysis/builtin_import_allowlist_cyber3_vuln_hunt_test.go diff --git a/analysis/builtin_import_allowlist_cyber3_vuln_hunt_test.go b/analysis/builtin_import_allowlist_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..69681bdf --- /dev/null +++ b/analysis/builtin_import_allowlist_cyber3_vuln_hunt_test.go @@ -0,0 +1,118 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Tripwire tests added by vuln-hunt campaign 2026-05-20-gpt-5.5-cyber-3 / +// builtin-import-allowlist. + +package analysis + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func TestVulnHuntSubsystemBuiltinImportAllowlist_AliasedUnlistedSymbolRejected(t *testing.T) { + root := repoRoot(t) + tmp := t.TempDir() + copyDir(t, filepath.Join(root, "builtins"), filepath.Join(tmp, "builtins")) + + writeGoFile(t, + filepath.Join(tmp, "builtins", "echo", "alias_symbol_rejected.go"), + "echo", + []string{`o "os"`}, + "var _ = o.Setenv\n", + ) + + var globalErrs []string + checkAllowedSymbols(t, builtinsVerifyCfg(tmp, &globalErrs)) + if !errContains(globalErrs, "os.Setenv") || !errContains(globalErrs, "not in the allowlist") { + t.Fatalf("expected aliased os.Setenv to be rejected by global builtin allowlist, got: %v", globalErrs) + } + + var perCmdErrs []string + cfg := builtinsPerCmdVerifyCfg(tmp, &perCmdErrs) + checkPerBuiltinAllowedSymbols(t, cfg) + if !errContains(perCmdErrs, "os") || !errContains(perCmdErrs, "not in the allowlist") { + t.Fatalf("expected aliased os.Setenv to be rejected by per-command allowlist, got: %v", perCmdErrs) + } +} + +func TestVulnHuntSubsystemBuiltinImportAllowlist_DotImportRejected(t *testing.T) { + root := repoRoot(t) + tmp := t.TempDir() + copyDir(t, filepath.Join(root, "builtins"), filepath.Join(tmp, "builtins")) + + writeGoFile(t, + filepath.Join(tmp, "builtins", "echo", "dot_import_rejected.go"), + "echo", + []string{`. "fmt"`}, + "var _ = Sprintf\n", + ) + + var errs []string + checkAllowedSymbols(t, builtinsVerifyCfg(tmp, &errs)) + if !errContains(errs, "blank/dot import") { + t.Fatalf("expected dot import to be rejected, got: %v", errs) + } +} + +func TestVulnHuntSubsystemBuiltinImportAllowlist_ParseErrorsFailClosed(t *testing.T) { + root := repoRoot(t) + tmp := t.TempDir() + copyDir(t, filepath.Join(root, "builtins"), filepath.Join(tmp, "builtins")) + + badPath := filepath.Join(tmp, "builtins", "echo", "parse_error.go") + if err := os.WriteFile(badPath, []byte("package echo\nfunc broken(\n"), 0o644); err != nil { + t.Fatal(err) + } + + var errs []string + checkAllowedSymbols(t, builtinsVerifyCfg(tmp, &errs)) + if !errContains(errs, "parse error") || !errContains(errs, "parse_error.go") { + t.Fatalf("expected parse error to fail closed, got: %v", errs) + } +} + +func TestVulnHuntSubsystemBuiltinImportAllowlist_PlatformSpecificInternalFilesChecked(t *testing.T) { + root := repoRoot(t) + cfg := internalCheckConfig() + files, err := cfg.CollectFiles(filepath.Join(root, "builtins", "internal")) + if err != nil { + t.Fatal(err) + } + + seen := make(map[string]bool, len(files)) + for _, path := range files { + rel, err := filepath.Rel(filepath.Join(root, "builtins", "internal"), path) + if err != nil { + t.Fatal(err) + } + seen[filepath.ToSlash(rel)] = true + } + + required := []string{ + "diskstats/diskstats_darwin.go", + "procnetsocket/procnetsocket_linux.go", + "winnet/winnet_windows.go", + "winpoll/winpoll_windows.go", + } + for _, rel := range required { + if !seen[rel] { + t.Fatalf("platform-specific internal file %s was not collected; got %s", rel, strings.Join(mapKeys(seen), ", ")) + } + } +} + +func mapKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} From 6699b7fbbdcdffd9413e341e79863d82e86b23e0 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 00:07:54 +0200 Subject: [PATCH 63/73] test: add blocked commands vuln hunt tripwires --- .../blocked_commands_cyber3_vuln_hunt_test.go | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 interp/blocked_commands_cyber3_vuln_hunt_test.go diff --git a/interp/blocked_commands_cyber3_vuln_hunt_test.go b/interp/blocked_commands_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..41ec389b --- /dev/null +++ b/interp/blocked_commands_cyber3_vuln_hunt_test.go @@ -0,0 +1,251 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: blocked_commands (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runBlockedCommandsCyber3(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "blocked_commands_cyber3_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + code := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureExpansionChain_BlockedCommandsExpandedSyntaxIsData(t *testing.T) { + stdout, stderr, code, err := runBlockedCommandsCyber3(t, `PAYLOAD='eval echo PWNED' +$PAYLOAD +T=trap +$T 'echo trapped' INT +TEXT='case x in x) echo BAD;; esac' +$TEXT +cat <<'EOF' +case x in x) echo HEREDOC;; esac +${#SECRET} +EOF +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "case x in x) echo HEREDOC;; esac\n${#SECRET}\n", stdout) + assert.Contains(t, stderr, "rshell: eval: unknown command") + assert.Contains(t, stderr, "rshell: trap: unknown command") + assert.Contains(t, stderr, "rshell: case: unknown command") + assert.NotContains(t, stdout, "PWNED") + assert.NotContains(t, stdout, "trapped") +} + +func TestVulnHuntShellFeatureParserConfusion_BlockedSyntaxPreExecValidation(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "arithmetic_expansion": {"echo $((1+2))\n", "arithmetic expansion is not supported"}, + "arithmetic_command": {"(( 1 + 2 ))\n", "arithmetic commands are not supported"}, + "process_substitution": {"cat <(echo BAD)\n", "process substitution is not supported"}, + "case_clause": {"case x in x) echo BAD;; esac\n", "case statements are not supported"}, + "function_decl": {"f() { echo BAD; }\n", "function declarations are not supported"}, + "test_clause": {"[[ -n hello ]]\n", "test expressions are not supported"}, + "decl_clause": {"readonly X=42\n", "readonly is not supported"}, + "let_clause": {"let \"x=1+2\"\n", "let is not supported"}, + "time_clause": {"time echo BAD\n", "time is not supported"}, + "coproc_clause": {"coproc echo BAD\n", "coprocesses are not supported"}, + "select_clause": {"select x in a b; do echo \"$x\"; done\n", "select statements are not supported"}, + "c_style_for": {"for ((i=0; i<1; i++)); do echo BAD; done\n", "c-style for loops are not supported"}, + "extglob": {"echo @(foo|bar)\n", "extended globbing is not supported"}, + "background": {"echo BAD &\n", "background execution (&) is not supported"}, + "pipe_all": {"echo BAD |& cat\n", "|& is not supported"}, + "tilde": {"echo ~\n", "tilde expansion is not supported"}, + "param_default": {"echo ${A:=mutated}\n", "${var} operations"}, + "positional": {"echo $1\n", "$1 is not supported"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runBlockedCommandsCyber3(t, "echo before\n"+tc.script+"echo after\n") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout, "whole-file validation must reject before any statement executes") + assert.Contains(t, stderr, tc.want) + assert.NotContains(t, stdout+stderr, "before") + assert.NotContains(t, stdout+stderr, "after") + assert.NotContains(t, stdout, "BAD") + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_BlockedCommandsDoNotMutateParent(t *testing.T) { + stdout, stderr, code, err := runBlockedCommandsCyber3(t, `X=keep +OUT=$(eval echo BAD) +echo out=[$OUT] +(unset X) +echo x=$X +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "out=[]\nx=keep\n", stdout) + assert.Contains(t, stderr, "rshell: eval: unknown command") + assert.Contains(t, stderr, "rshell: unset: unknown command") + assert.NotContains(t, stdout, "BAD") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_BlockedCommandStatusesAndCaps(t *testing.T) { + _, err := ParseScript(strings.Repeat(" ", MaxScriptBytes+1), "blocked_commands_oversized.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + assert.Contains(t, err.Error(), "5 MiB") + + stdout, stderr, code, runErr := runBlockedCommandsCyber3(t, "eval echo hi\n") + require.NoError(t, runErr) + assert.Equal(t, 127, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "rshell: eval: unknown command") + + stdout, stderr, code, runErr = runBlockedCommandsCyber3(t, "case x in x) echo BAD;; esac\n") + require.NoError(t, runErr) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Equal(t, "case statements are not supported\n", stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_RuntimeBlockedBuiltinsDoNotExecuteHostFiles(t *testing.T) { + dir := t.TempDir() + payload := filepath.Join(dir, "payload.sh") + marker := filepath.Join(dir, "marker") + require.NoError(t, os.WriteFile(payload, []byte("#!/bin/sh\ntouch "+shellQuoteBlockedCommandsCyber3(marker)+"\n"), 0o755)) + + stdout, stderr, code, err := runBlockedCommandsCyber3(t, strings.Join([]string{ + "eval " + shellQuoteBlockedCommandsCyber3(payload), + "exec " + shellQuoteBlockedCommandsCyber3(payload), + "command " + shellQuoteBlockedCommandsCyber3(payload), + ". " + shellQuoteBlockedCommandsCyber3(payload), + "echo done", + "", + }, "\n")) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "done\n", stdout) + assert.Contains(t, stderr, "rshell: eval: unknown command") + assert.Contains(t, stderr, "rshell: exec: unknown command") + assert.Contains(t, stderr, "rshell: command: unknown command") + assert.Contains(t, stderr, "rshell: .: unknown command") + assert.NoFileExists(t, marker) +} + +func TestVulnHuntShellFeatureCompositionAttack_BlockedCommandRedirectionsRestore(t *testing.T) { + stdout, stderr, code, err := runBlockedCommandsCyber3(t, `eval echo hidden 2>/dev/null +echo stderr_redir=$? +eval echo hidden >/dev/null +echo stdout_redir=$? +eval hidden | cat >/dev/null +echo pipe=$? +echo ok +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "stderr_redir=127\nstdout_redir=127\npipe=0\nok\n", stdout) + assert.Equal(t, 2, strings.Count(stderr, "rshell: eval: unknown command")) +} + +func TestVulnHuntShellFeatureReadonlyBypass_BlockedDeclsAndParamOpsDoNotMutate(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "readonly RO=1\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "RO=ok\necho $RO\n")) + require.NoError(t, err) + assert.Equal(t, "ok\n", stdout.String()) + assert.Empty(t, stderr.String()) + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "X=original\n")) + require.NoError(t, err) + err = r.Run(context.Background(), parseScript(t, "echo ${X:=mutated}\n")) + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "X=tmp eval noop\necho $X\n")) + require.NoError(t, err) + assert.Equal(t, "original\n", stdout.String()) + assert.Contains(t, stderr.String(), "rshell: eval: unknown command") +} + +func TestVulnHuntShellFeatureSignalContext_BlockedCommandStormHonorsCancellation(t *testing.T) { + r, err := New(StdIO(nil, io.Discard, io.Discard), allowAllCommandsOpt(), MaxExecutionTime(25*time.Millisecond)) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "while true; do eval noop; done\n")) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestVulnHuntShellFeatureSignalContext_PreCanceledBlockedCommandIsSilent(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = r.Run(ctx, parseScript(t, "eval echo hi\n")) + + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func shellQuoteBlockedCommandsCyber3(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} From 4962d5630eddb3da8c386e915820f180a0b7d399 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 00:19:00 +0200 Subject: [PATCH 64/73] test: add blocked redirects vuln hunt tripwires --- ...blocked_redirects_cyber3_vuln_hunt_test.go | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 interp/blocked_redirects_cyber3_vuln_hunt_test.go diff --git a/interp/blocked_redirects_cyber3_vuln_hunt_test.go b/interp/blocked_redirects_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..b1dd567a --- /dev/null +++ b/interp/blocked_redirects_cyber3_vuln_hunt_test.go @@ -0,0 +1,228 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: blocked_redirects (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runBlockedRedirectsCyber3(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "blocked_redirects_cyber3_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + code := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureParserConfusion_BlockedRedirectsRejectBeforeExecution(t *testing.T) { + tests := map[string]struct { + script string + want string + }{ + "write_truncate": {"echo data > out.txt\n", "> file redirection is not supported"}, + "write_clobber": {"echo data >| out.txt\n", "> file redirection is not supported"}, + "append": {"echo data >> out.txt\n", ">> file redirection is not supported"}, + "write_all": {"echo data &> out.txt\n", "&> file redirection is not supported"}, + "append_all": {"echo data &>> out.txt\n", "&>> file redirection is not supported"}, + "read_write": {"cat <> out.txt\n", "<> file redirection is not supported"}, + "herestring": {"cat <<< hello\n", "<<< (herestring) is not supported"}, + "input_dup": {"echo data <&0\n", "<&N fd duplication is not supported"}, + "output_dup_close": {"echo data >&-\n", ">&N fd duplication is not supported"}, + "output_dup_bad_src": {"echo data 3>&1\n", ">&N fd duplication is not supported"}, + "output_dup_bad_dst": {"echo data >&3\n", ">&N fd duplication is not supported"}, + "input_fd3": {"echo data 3< input.txt\n", "3< input fd redirection is not supported"}, + "dynamic_output": {"TARGET=/dev/null\necho data > \"$TARGET\"\n", "> file redirection is not supported"}, + "fd0_output_to_devnul": {"echo data 0>/dev/null\n", "> file redirection is not supported"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code, err := runBlockedRedirectsCyber3(t, + "echo before\n"+tc.script+"echo after\n", + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout, "whole-file validation must reject before any statement executes") + assert.Contains(t, stderr, tc.want) + assert.NotContains(t, stdout+stderr, "before") + assert.NotContains(t, stdout+stderr, "after") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + }) + } +} + +func TestVulnHuntShellFeatureExpansionChain_DynamicNullTargetsStayBlocked(t *testing.T) { + for _, script := range []string{ + "TARGET=/dev/null\necho hi > $TARGET\n", + "echo hi > \"$(printf /dev/null)\"\n", + "echo hi > '/dev/null'\n", + "echo hi > /dev//null\n", + "echo hi > /dev/./null\n", + "echo hi > /dev/null/\n", + "echo hi > /dev/null/../null\n", + } { + t.Run(script, func(t *testing.T) { + stdout, stderr, code, err := runBlockedRedirectsCyber3(t, "echo before\n"+script+"echo after\n") + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "file redirection is not supported") + assert.NotContains(t, stdout+stderr, "before") + assert.NotContains(t, stdout+stderr, "after") + }) + } +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_SupportedNullRedirectsStillWork(t *testing.T) { + stdout, stderr, code, err := runBlockedRedirectsCyber3(t, `echo hidden >/dev/null +echo visible +no_such_command 2>/dev/null +echo status=$? +echo both &>/dev/null +echo append >>/dev/null +echo append_all &>>/dev/null +echo err >&2 +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "visible\nstatus=127\n", stdout) + assert.Equal(t, "err\n", stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_BlockedRedirectsDoNotOpenTargets(t *testing.T) { + for _, script := range []string{ + "echo hi > fifo\n", + "cat <> fifo\n", + "echo hi 3< fifo\n", + "echo hi >> fifo\n", + "cat <<< data\n", + } { + t.Run(script, func(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.openHandler = func(context.Context, string, int, os.FileMode) (io.ReadWriteCloser, error) { + t.Fatalf("blocked redirect unexpectedly reached openHandler for script %q", script) + return nil, nil + } + + err = r.Run(context.Background(), parseScript(t, script)) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.NotEmpty(t, stderr.String()) + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_BlockedRedirectInCompoundRejectsGlobally(t *testing.T) { + for _, script := range []string{ + "(echo hidden > out.txt)\necho after\n", + "{ echo hidden > out.txt; }\necho after\n", + "echo $(echo hidden > out.txt)\necho after\n", + "echo left | echo hidden > out.txt\necho after\n", + } { + t.Run(script, func(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code, err := runBlockedRedirectsCyber3(t, script, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "> file redirection is not supported") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + }) + } +} + +func TestVulnHuntShellFeatureRedirectionChain_MixedAllowedBlockedRedirectsFailClosed(t *testing.T) { + for _, script := range []string{ + "echo before\necho hidden >/dev/null > out.txt\necho after\n", + "echo before\necho hidden 2>&1 > out.txt\necho after\n", + "echo before\necho hidden &>/dev/null > out.txt\necho after\n", + } { + t.Run(script, func(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code, err := runBlockedRedirectsCyber3(t, script, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "> file redirection is not supported") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + }) + } +} + +func TestVulnHuntShellFeatureCompositionAttack_InvalidRedirectPreventsStateChanges(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), parseScript(t, "X=original\n")) + require.NoError(t, err) + + err = r.Run(context.Background(), parseScript(t, "X=changed\necho hi > out.txt\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "echo $X\n")) + require.NoError(t, err) + assert.Equal(t, "original\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_MaxScriptBytesRejectsHugeBlockedRedirects(t *testing.T) { + line := "echo hi > out.txt\n" + script := strings.Repeat(line, MaxScriptBytes/len(line)+1) + require.Greater(t, len(script), MaxScriptBytes) + + _, err := ParseScript(script, "blocked_redirects_oversized.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + assert.Contains(t, err.Error(), "5 MiB") +} From 757f5292be0f71918e61b90e98d909e5fc40c3bd Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 00:34:34 +0200 Subject: [PATCH 65/73] test: add for clause vuln hunt tripwires --- interp/for_clause_cyber3_vuln_hunt_test.go | 288 +++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 interp/for_clause_cyber3_vuln_hunt_test.go diff --git a/interp/for_clause_cyber3_vuln_hunt_test.go b/interp/for_clause_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..1d7b7a94 --- /dev/null +++ b/interp/for_clause_cyber3_vuln_hunt_test.go @@ -0,0 +1,288 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: for_clause (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runForClauseCyber3(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "for_clause_cyber3_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + code := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureExpansionChain_ForItemsRemainData(t *testing.T) { + stdout, stderr, code, err := runForClauseCyber3(t, `for item in "$(printf 'echo HACKED')" 'semi;echo HACKED' '$(echo nope)' 'x > out.txt'; do + echo "item=[$item]" +done +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "item=[echo HACKED]\nitem=[semi;echo HACKED]\nitem=[$(echo nope)]\nitem=[x > out.txt]\n", stdout) + assert.Empty(t, stderr) + assert.NotContains(t, stdout, "\nHACKED\n") +} + +func TestVulnHuntShellFeatureExpansionChain_ForItemCatShortcutPolicyPrecedesBody(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("top secret\n"), 0o644)) + + prog := parseScript(t, `for item in $(/dev/null +echo "after_pipe=$PIPE/$item" +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "sub=b/b\nparent=outer/\nafter_pipe=/\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_ForLoopControlDoesNotLeakCounters(t *testing.T) { + stdout, stderr, code, err := runForClauseCyber3(t, `for i in 1; do break 99; done +for j in a b; do echo "$j"; done +for k in 1 2; do continue 99; echo bad; done +echo after +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb\nafter\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_MaxScriptBytesRejectsHugeForLoop(t *testing.T) { + line := "for i in a b c; do echo $i; done\n" + script := strings.Repeat(line, MaxScriptBytes/len(line)+1) + require.Greater(t, len(script), MaxScriptBytes) + + _, err := ParseScript(script, "for_clause_oversized.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + assert.Contains(t, err.Error(), "5 MiB") +} + +func TestVulnHuntShellFeatureCompositionAttack_GlobbedForItemsRemainData(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"$(echo hacked).txt", "semi;echo hacked.txt", "redir>out.txt", "plain.txt"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644)) + } + + stdout, stderr, code, err := runForClauseCyber3(t, + `for item in *; do echo "[$item]"; done +`, + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "[$(echo hacked).txt]\n") + assert.Contains(t, stdout, "[semi;echo hacked.txt]\n") + assert.Contains(t, stdout, "[redir>out.txt]\n") + assert.Contains(t, stdout, "[plain.txt]\n") + assert.Empty(t, stderr) + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntShellFeatureCompositionAttack_ForGlobReadDirCapIsShared(t *testing.T) { + dir := t.TempDir() + args := make([]string, MaxGlobReadDirCalls+1) + for i := range args { + args[i] = fmt.Sprintf("nomatch_%d_*", i) + } + + stdout, stderr, code, err := runForClauseCyber3(t, + "for item in "+strings.Join(args, " ")+"; do :; done\n", + AllowedPaths([]string{dir}), + ) + + require.NoError(t, err) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "glob expansion exceeded maximum number of directory reads") +} + +func TestVulnHuntShellFeatureRedirectionChain_ForBlockedRedirectRejectsGlobally(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + require.NoError(t, r.Run(context.Background(), parseScript(t, "X=original\n"))) + err = r.Run(context.Background(), parseScript(t, "X=changed\nfor item in a; do echo body > out.txt; done\necho after\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "> file redirection is not supported") + + stdout.Reset() + stderr.Reset() + require.NoError(t, r.Run(context.Background(), parseScript(t, "echo $X\n"))) + assert.Equal(t, "original\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureRedirectionChain_ForInputRedirectUsesLoopVariableSandbox(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + secret := filepath.Join(base, "secret") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(secret, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "data.txt"), []byte("safe\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("top-secret-data\n"), 0o644)) + + stdout, stderr, code, err := runForClauseCyber3(t, `for file in data.txt ../secret/secret.txt data.txt; do + cat < "$file" + echo "status=$?" +done +cat < Date: Thu, 21 May 2026 00:43:41 +0200 Subject: [PATCH 66/73] test: add readonly vuln hunt tripwires --- interp/readonly_cyber3_vuln_hunt_test.go | 238 +++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 interp/readonly_cyber3_vuln_hunt_test.go diff --git a/interp/readonly_cyber3_vuln_hunt_test.go b/interp/readonly_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..e18203db --- /dev/null +++ b/interp/readonly_cyber3_vuln_hunt_test.go @@ -0,0 +1,238 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: readonly (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + "time" + + "mvdan.cc/sh/v3/expand" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newReadonlyCyber3Runner(t *testing.T, stdin io.Reader, opts ...RunnerOption) (*Runner, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(stdin, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO_VAR", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + return r, &stdout, &stderr +} + +func runReadonlyCyber3Script(t *testing.T, script string, stdin io.Reader, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + r, stdout, stderr := newReadonlyCyber3Runner(t, stdin, opts...) + err := r.Run(context.Background(), parseScript(t, script)) + code := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureParserConfusion_ReadonlyValidationPrecedesExecution(t *testing.T) { + tests := map[string]string{ + "plain": "readonly X=1\n", + "print": "readonly -p\n", + "separator": "readonly -- X=1\n", + "group": "{ readonly X=1; }\n", + "cmdsubst": "echo $(readonly X=1; echo bad)\n", + "redirect": "readonly X=1 > out.txt\n", + } + + for name, tail := range tests { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.openHandler = func(context.Context, string, int, os.FileMode) (io.ReadWriteCloser, error) { + t.Fatalf("readonly validation reached openHandler for %s", name) + return nil, nil + } + + err = r.Run(context.Background(), parseScript(t, "X=changed\n"+tail+"echo after\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "readonly is not supported") + }) + } +} + +func TestVulnHuntShellFeatureExpansionChain_ReadonlyAssignmentVectorsPreserveValue(t *testing.T) { + tests := map[string]struct { + script string + stdin io.Reader + wantStdout string + wantStderr string + notInStdout string + expectedCode int + }{ + "direct_assignment": { + script: "RO_VAR=hacked\necho after=$RO_VAR\n", + wantStdout: "after=original\n", + wantStderr: "readonly variable", + notInStdout: "hacked", + }, + "inline_assignment": { + script: "RO_VAR=hacked echo HIT\necho after=$RO_VAR\n", + wantStdout: "after=original\n", + wantStderr: "readonly variable", + notInStdout: "HIT", + }, + "read_builtin": { + script: "read RO_VAR\necho after=$RO_VAR\n", + stdin: strings.NewReader("hacked\n"), + wantStdout: "after=original\n", + wantStderr: "readonly variable", + notInStdout: "hacked", + }, + "assigning_parameter_expansion": { + script: "echo ${RO_VAR:=hacked}\necho after=$RO_VAR\n", + wantStdout: "", + wantStderr: "not supported", + notInStdout: "hacked", + expectedCode: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runReadonlyCyber3Script(t, tc.script, tc.stdin) + + require.NoError(t, err) + if tc.expectedCode != 0 { + assert.Equal(t, tc.expectedCode, code) + } + assert.Contains(t, stdout, tc.wantStdout) + assert.Contains(t, stderr, tc.wantStderr) + assert.NotContains(t, stdout, tc.notInStdout) + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_ReadonlyPropagatesToChildren(t *testing.T) { + tests := map[string]string{ + "subshell": "( RO_VAR=hacked; echo inside=$RO_VAR )\necho after=$RO_VAR\n", + "pipeline": "echo seed | { RO_VAR=hacked; echo inside=$RO_VAR; }\necho after=$RO_VAR\n", + "pipe_paren": "echo seed | ( RO_VAR=hacked; echo inside=$RO_VAR; )\necho after=$RO_VAR\n", + "cmdsubst": "echo got=$(RO_VAR=hacked; echo $RO_VAR)\necho after=$RO_VAR\n", + } + + for name, script := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runReadonlyCyber3Script(t, script, nil) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Contains(t, stderr, "readonly variable") + assert.NotContains(t, stdout, "hacked") + assert.Contains(t, stdout, "after=original") + }) + } +} + +func TestVulnHuntShellFeatureCompositionAttack_MixedInlineRestorePreservesReadonly(t *testing.T) { + stdout, stderr, code, err := runReadonlyCyber3Script(t, `FOO=ok RO_VAR=evil echo HIT +echo "after foo=$FOO ro=$RO_VAR" +RO_VAR=again +echo "check=$RO_VAR" +`, nil) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Contains(t, stderr, "readonly variable") + assert.NotContains(t, stdout, "HIT") + assert.Contains(t, stdout, "after foo= ro=original") + assert.Contains(t, stdout, "check=original") +} + +func TestVulnHuntShellFeatureRedirectionChain_FailedRedirectDoesNotDowngradeReadonly(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code, err := runReadonlyCyber3Script(t, `RO_VAR=hacked < missing.txt +echo "after=$RO_VAR" +RO_VAR=again +echo "check=$RO_VAR" +`, nil, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "after=original\n") + assert.Contains(t, stdout, "check=original\n") + assert.Contains(t, stderr, "no such file") + assert.Contains(t, stderr, "readonly variable") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_WriteEnvRejectsReadonlyChanges(t *testing.T) { + r, _, _ := newReadonlyCyber3Runner(t, nil) + + assert.ErrorContains(t, r.writeEnv.Set("RO_VAR", expand.Variable{Set: true, Kind: expand.String, Str: "changed"}), "readonly variable") + assert.ErrorContains(t, r.writeEnv.Set("RO_VAR", expand.Variable{}), "readonly variable") + assert.ErrorContains(t, r.writeEnv.Set("RO_VAR", expand.Variable{Kind: expand.KeepValue, Exported: true}), "readonly variable") + + vr := r.writeEnv.Get("RO_VAR") + assert.True(t, vr.ReadOnly) + assert.Equal(t, "original", vr.Str) +} + +func TestVulnHuntShellFeatureSignalContext_ReadonlyReadHonorsCancellation(t *testing.T) { + pr, pw, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { + _ = pr.Close() + _ = pw.Close() + }) + + var stdout, stderr bytes.Buffer + r, err := New( + StdIO(pr, &stdout, &stderr), + allowAllCommandsOpt(), + MaxExecutionTime(40*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO_VAR", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + + err = r.Run(context.Background(), parseScript(t, "read RO_VAR\n")) + + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout.String()) + assert.NotContains(t, stderr.String(), "hacked") + assert.Equal(t, "original", r.writeEnv.Get("RO_VAR").Str) +} From 6968529ad00894c58d19ff429c46731bad5e8d9c Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 00:50:13 +0200 Subject: [PATCH 67/73] test: add comments vuln hunt tripwires --- interp/comments_cyber3_vuln_hunt_test.go | 223 +++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 interp/comments_cyber3_vuln_hunt_test.go diff --git a/interp/comments_cyber3_vuln_hunt_test.go b/interp/comments_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..04345a95 --- /dev/null +++ b/interp/comments_cyber3_vuln_hunt_test.go @@ -0,0 +1,223 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: comments (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCommentsCyber3(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "comments_cyber3_vuln_hunt.sh") + if err != nil { + return "", err.Error() + "\n", 2, nil + } + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + code := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureExpansionChain_CommentTextIsNeverExecuted(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `# $(echo HACKED) +# `+"`echo BACKTICK`"+` +# readonly X=1 +# echo hidden > out.txt +echo visible +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "visible\n", stdout) + assert.Empty(t, stderr) + assert.NotContains(t, stdout+stderr, "HACKED") + assert.NotContains(t, stdout+stderr, "BACKTICK") + assert.NotContains(t, stdout+stderr, "readonly") +} + +func TestVulnHuntShellFeatureExpansionChain_HashLiteralContexts(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `echo "quoted # data" +echo 'single # data' +echo escaped \# data +V=mid#word +echo "var=$V" +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "quoted # data\nsingle # data\nescaped # data\nvar=mid#word\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_CommentsAfterOperatorsPreserveStructure(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `echo one; # semicolon comment +echo two &&# and comment +echo three ||# or comment +echo skipped +echo pipe |# pipe comment +cat +for v # for variable comment +in a b # item comment +do # do comment + echo "loop=$v" # body comment +done # done comment +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "one\ntwo\nthree\npipe\nloop=a\nloop=b\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureParserConfusion_CommentOnlyAndCRLFInputsStayNoop(t *testing.T) { + tests := map[string]string{ + "comment_only": "# $(echo HACKED)\n# > out.txt\n###\n", + "indented": " # comment\n\t# tab comment\n", + "crlf": "echo one # ignored\r\necho two\r\n", + } + + for name, script := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, script) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + if name == "crlf" { + assert.Equal(t, "one\ntwo\n", stdout) + } else { + assert.Empty(t, stdout) + } + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_CommentsDoNotMoveCompoundBoundaries(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `( X=child # subshell comment +echo "sub=$X" ) +echo "parent=$X" +{ Y=block # brace comment +echo "block=$Y"; } +echo "after_block=$Y" +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "sub=child\nparent=\nblock=block\nafter_block=block\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_MaxScriptBytesRejectsHugeComments(t *testing.T) { + line := "# comment with ignored payload $(echo HACKED) > out.txt\n" + script := strings.Repeat(line, MaxScriptBytes/len(line)+1) + require.Greater(t, len(script), MaxScriptBytes) + + _, err := ParseScript(script, "comments_oversized.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + assert.Contains(t, err.Error(), "5 MiB") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_CommentPreservesPreviousStatus(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `false # ignored comment +echo "status=$?" +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureCompositionAttack_CommentBackslashDoesNotContinueLine(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `# comment ending with backslash \ +echo visible +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "visible\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureCompositionAttack_HeredocHashContentIsLiteral(t *testing.T) { + stdout, stderr, code, err := runCommentsCyber3(t, `cat < out.txt +`, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "visible\n", stdout) + assert.Empty(t, stderr) + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) + + stdout, stderr, code, err = runCommentsCyber3(t, `echo hidden > out.txt # comment +echo after +`, AllowedPaths([]string{dir})) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "> file redirection is not supported") + assert.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntShellFeatureRedirectionChain_InputRedirectCommentRestoresStdin(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("from-file\n"), 0o644)) + + stdout, stderr, code, err := runCommentsCyber3(t, `cat < input.txt # comment after input redirect +cat < Date: Thu, 21 May 2026 05:50:38 +0200 Subject: [PATCH 68/73] test: add heredoc dash vuln hunt tripwires --- .../heredoc_dash_cyber3_vuln_hunt_test.go | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 interp/tests/heredoc_dash_cyber3_vuln_hunt_test.go diff --git a/interp/tests/heredoc_dash_cyber3_vuln_hunt_test.go b/interp/tests/heredoc_dash_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..156c3715 --- /dev/null +++ b/interp/tests/heredoc_dash_cyber3_vuln_hunt_test.go @@ -0,0 +1,227 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: heredoc_dash (shell-feature) + +package tests_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +func TestVulnHuntShellFeatureExpansionChain_DashHeredocTabsIntroducedByExpansionRemain(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, "LINE=$'\\tkept\\tvalue'\ncat <<-EOF\n\t$LINE\nEOF\n", dir) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "\tkept\tvalue\n", stdout) +} + +func TestVulnHuntShellFeatureExpansionChain_DashHeredocExpandedMetacharactersStayData(t *testing.T) { + dir := t.TempDir() + script := "PAYLOAD='echo HACKED; cat < secret.txt'\ncat <<-EOF\n\t$PAYLOAD\nEOF\n" + + stdout, stderr, code := redirRun(t, script, dir) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "echo HACKED; cat < secret.txt\n", stdout) +} + +func TestVulnHuntShellFeatureExpansionChain_DashHeredocCatShortcutPolicyAndSandbox(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + forbidden := filepath.Join(base, "forbidden") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(forbidden, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "secret.txt"), []byte("INSIDE\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("OUTSIDE\n"), 0o644)) + + stdout1, stderr1, code1 := redirRunWithOpts(t, "read X <<-EOF\n\t$( file redirection is not supported") + require.NoFileExists(t, filepath.Join(dir, "out.txt")) + assert.NotContains(t, stdout+stderr, "BAD") +} + +func TestVulnHuntShellFeatureSignalContext_DashHeredocWriterHonorsCancel(t *testing.T) { + if testing.Short() { + t.Skip("skipping ctx-cancel timing test in -short mode") + } + body := strings.Repeat("y", interp.MaxHeredocBytes-1) + script := "cat <<-EOF\n" + body + "\nEOF" + + prog, err := syntax.NewParser().Parse(strings.NewReader(script), "") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + pentestRedirRunProg(ctx, t, prog, t.TempDir()) + close(done) + }() + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("dash heredoc writer did not honor ctx cancel within budget") + } +} From 93ab0ce2e645a7a0a8870e2b9aa9a31f0ec84422 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 08:02:58 +0200 Subject: [PATCH 69/73] test: add logic ops vuln hunt tripwires --- interp/logic_ops_cyber3_vuln_hunt_test.go | 260 ++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 interp/logic_ops_cyber3_vuln_hunt_test.go diff --git a/interp/logic_ops_cyber3_vuln_hunt_test.go b/interp/logic_ops_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..f16cc408 --- /dev/null +++ b/interp/logic_ops_cyber3_vuln_hunt_test.go @@ -0,0 +1,260 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: logic_ops (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" +) + +func runLogicOpsVulnHuntScript(t *testing.T, script string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "logic_ops_vuln_hunt.sh") + if err != nil { + return "", err.Error() + "\n", 2, nil + } + + var stdout, stderr bytes.Buffer + allOpts := []RunnerOption{StdIO(nil, &stdout, &stderr)} + if len(opts) == 0 { + allOpts = append(allOpts, allowAllCommandsOpt()) + } else { + allOpts = append(allOpts, opts...) + } + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + err = r.Run(context.Background(), prog) + exitCode := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + exitCode = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +func TestVulnHuntShellFeatureExpansionChain_LogicOpsSkippedRightDoesNotExpand(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + forbidden := filepath.Join(base, "forbidden") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(forbidden, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("SECRET\n"), 0o644)) + + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, + "true || echo \"$(<../forbidden/secret.txt)\"\necho after\n", + AllowedPaths([]string{allowed}), + AllowedCommands([]string{"rshell:true", "rshell:echo"})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "after\n", stdout) + assert.Empty(t, stderr) + assert.NotContains(t, stdout, "SECRET") +} + +func TestVulnHuntShellFeatureExpansionChain_LogicOpsExpandedOperatorsStayData(t *testing.T) { + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, "PAYLOAD='false || echo HACKED'\necho \"$PAYLOAD\" && echo done\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "false || echo HACKED\ndone\n", stdout) +} + +func TestVulnHuntShellFeatureExpansionChain_LogicOpsCommandSubstCapAndStatusHold(t *testing.T) { + payload := strings.Repeat("z", MaxVarBytes+1) + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, "echo \"$(printf '"+payload+"')\" && echo ok\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, strings.Repeat("z", MaxVarBytes)+"\nok\n", stdout) +} + +func TestVulnHuntShellFeatureParserConfusion_LogicOpsGroupingAndLinebreaks(t *testing.T) { + script := `false || +# comment between operator and operand +echo recovered && echo after; false && echo skipped; echo done +` + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, script) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "recovered\nafter\ndone\n", stdout) +} + +func TestVulnHuntShellFeatureParserConfusion_LogicOpsValidationPrecedesExecution(t *testing.T) { + tests := map[string]string{ + "pipe_all": "echo before\nfalse |& echo hidden || echo fallback\n", + "skipped_function": "echo before\ntrue || f() { echo hidden; }\necho after\n", + } + + for name, script := range tests { + t.Run(name, func(t *testing.T) { + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, script) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.NotContains(t, stdout+stderr, "before") + assert.NotContains(t, stdout+stderr, "hidden") + assert.NotContains(t, stdout+stderr, "after") + }) + } +} + +func TestVulnHuntShellFeatureSubshellIsolation_LogicOpsSubshellOperandDoesNotLeak(t *testing.T) { + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, "X=parent\n(X=child; true) && echo first=$X\n(X=bad; false) || echo second=$X\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "first=parent\nsecond=parent\n", stdout) +} + +func TestVulnHuntShellFeatureSubshellIsolation_LogicOpsPipelineStatusAndStateHold(t *testing.T) { + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, "false | true && echo right\ntrue | false || echo fallback\nX=parent; echo child | read X && echo \"$X\"\n") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "right\nfallback\nparent\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_MaxScriptBytesRejectsHugeLogicOps(t *testing.T) { + unit := "true && " + script := strings.Repeat(unit, MaxScriptBytes/len(unit)+1) + "true\n" + + _, err := ParseScript(script, "logic_ops_oversized.sh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_LogicOpsExitStatusLastExecuted(t *testing.T) { + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, `false && exit 7 || echo fallback +echo after=$? +true && false || true && false +echo chain=$? +`) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "fallback\nafter=0\nchain=1\n", stdout) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_LogicOpsTimeoutPropagates(t *testing.T) { + stdout, _, _, err := runLogicOpsVulnHuntScript(t, + "false || while true; do true; done\necho never\n", + allowAllCommandsOpt(), + MaxExecutionTime(50*time.Millisecond)) + + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout) +} + +func TestVulnHuntShellFeatureCompositionAttack_LogicOpsRedirectionSkippedOrRestored(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + forbidden := filepath.Join(base, "forbidden") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(forbidden, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "data.txt"), []byte("data\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("SECRET\n"), 0o644)) + + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, + "true || cat < ../forbidden/secret.txt\ncat < data.txt && cat <<'EOF'\nheredoc\nEOF\n", + AllowedPaths([]string{allowed}), + AllowedCommands([]string{"rshell:true", "rshell:cat"})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "data\nheredoc\n", stdout) + assert.NotContains(t, stdout, "SECRET") +} + +func TestVulnHuntShellFeatureCompositionAttack_LogicOpsBlockedRedirectFailsGlobally(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, "true || echo skipped > out.txt\necho after\n", + AllowedPaths([]string{dir}), + allowAllCommandsOpt()) + + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "> file redirection is not supported") + require.NoFileExists(t, filepath.Join(dir, "out.txt")) +} + +func TestVulnHuntShellFeatureRedirectionChain_LogicOpsExecutedInputRedirectSandboxed(t *testing.T) { + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + forbidden := filepath.Join(base, "forbidden") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(forbidden, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(forbidden, "secret.txt"), []byte("SECRET\n"), 0o644)) + + stdout, stderr, code, err := runLogicOpsVulnHuntScript(t, + "P=../forbidden/secret.txt\ncat < $P || echo denied\n", + AllowedPaths([]string{allowed}), + AllowedCommands([]string{"rshell:cat", "rshell:echo"})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "denied\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "SECRET") +} + +func TestVulnHuntShellFeatureReadonlyBypass_LogicOpsReadonlyFailuresDoNotRunSkippedSide(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO", expand.Variable{Set: true, Kind: expand.String, Str: "original", ReadOnly: true})) + + err = r.Run(context.Background(), parseScript(t, "RO=hacked echo bad && echo right\ntrue || RO=bad echo skipped\necho after=$? ro=$RO\n")) + + require.NoError(t, err) + assert.Equal(t, "after=0 ro=original\n", stdout.String()) + assert.Contains(t, stderr.String(), "readonly variable") + assert.NotContains(t, stdout.String(), "bad") + assert.NotContains(t, stdout.String(), "right") + assert.NotContains(t, stdout.String(), "skipped") +} + +func TestVulnHuntShellFeatureSignalContext_LogicOpsCancellationDuringCmdSubstIsFatal(t *testing.T) { + stdout, _, _, err := runLogicOpsVulnHuntScript(t, + "echo \"$(while true; do echo x; done)\" && echo never\n", + allowAllCommandsOpt(), + MaxExecutionTime(50*time.Millisecond)) + + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, stdout) +} From 27a4eca933712f588def0964d2a8bc3e49ce31e6 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 09:07:34 +0200 Subject: [PATCH 70/73] test: add continue vuln hunt tripwires --- .../continue_cyber3_vuln_hunt_test.go | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 builtins/continue/continue_cyber3_vuln_hunt_test.go diff --git a/builtins/continue/continue_cyber3_vuln_hunt_test.go b/builtins/continue/continue_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..849f32a6 --- /dev/null +++ b/builtins/continue/continue_cyber3_vuln_hunt_test.go @@ -0,0 +1,220 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: continue (builtin) + +package continuecmd_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/interp" +) + +func runContinueVulnHunt(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, "", opts...) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_ContinueHelpDoesNotMutateLoopControl(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, + "for i in 1 2; do continue --help; echo after-$i; done; echo done\n") + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, 2, strings.Count(stdout, "continue: continue [n]")) + assert.Contains(t, stdout, "after-1\n") + assert.Contains(t, stdout, "after-2\n") + assert.True(t, strings.HasSuffix(stdout, "done\n")) +} + +func TestVulnHuntBuiltinFlagDrivenExploit_FlagShapedOperandsValidateAsNumbers(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, "for i in 1; do continue --; echo after; done; echo done\n") + assert.Equal(t, 128, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "continue: --: numeric argument required") + + stdout, stderr, code = runContinueVulnHunt(t, + "for i in 1 2; do\n"+ + " for j in a b; do\n"+ + " echo in-$i-$j\n"+ + " continue +2\n"+ + " echo inner-unreachable\n"+ + " done\n"+ + " echo outer-unreachable\n"+ + "done\n"+ + "echo done\n") + assert.Equal(t, 0, code) + assert.Equal(t, "in-1-a\nin-2-a\ndone\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinDeclaredVsImplemented_OutsideLoopContinueDoesNotPoisonFlow(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, + "continue\n"+ + "echo status=$?\n"+ + "false || continue\n"+ + "echo after-or\n"+ + "continue && echo after-and\n"+ + "echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "status=0\nafter-or\nafter-and\ndone\n", stdout) + assert.Equal(t, 3, strings.Count(stderr, "continue is only useful in a loop")) +} + +func TestVulnHuntBuiltinResourceExhaustion_HugeContinueLevelsClampAtOutermost(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, + "for i in 1 2 3; do\n"+ + " echo before-$i\n"+ + " continue 999999\n"+ + " echo unreachable\n"+ + "done\n"+ + "echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "before-1\nbefore-2\nbefore-3\ndone\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinResourceExhaustion_InfiniteContinueRespectsContext(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + stdout, stderr, _ := testutil.RunScriptCtx(ctx, t, "while true; do continue; done\n", "") + + assert.Less(t, time.Since(start), 2*time.Second) + assert.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntBuiltinIntegerOverflow_InvalidArgumentsAbort(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, "for i in 1; do continue abc; echo after; done; echo done\n") + assert.Equal(t, 128, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "continue: abc: numeric argument required") + + stdout, stderr, code = runContinueVulnHunt(t, "for i in 1; do continue 1 2; echo after; done; echo done\n") + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "continue: too many arguments") + + stdout, stderr, code = runContinueVulnHunt(t, "for i in 1; do continue 99999999999999999999; echo after; done; echo done\n") + assert.Equal(t, 128, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "numeric argument required") +} + +func TestVulnHuntBuiltinIntegerOverflow_ZeroAndNegativeContinueBreakCompatibly(t *testing.T) { + for _, arg := range []string{"0", "-1"} { + t.Run(arg, func(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, + "for i in 1 2; do echo before-$i; continue "+arg+"; echo after-$i; done; echo done\n") + + assert.Equal(t, 0, code) + assert.Equal(t, "before-1\ndone\n", stdout) + assert.Contains(t, stderr, "loop count out of range") + }) + } +} + +func TestVulnHuntBuiltinControlFlow_ContinueAcrossLoopTypesAndLists(t *testing.T) { + stdout, stderr, code := runContinueVulnHunt(t, + "for i in 1 2; do echo for-$i; continue; echo for-bad; done\n"+ + "while read line; do echo while-$line; continue; echo while-bad; done < Date: Thu, 21 May 2026 09:18:21 +0200 Subject: [PATCH 71/73] test: add output buffer vuln hunt tripwires --- interp/output_buffer_cyber3_vuln_hunt_test.go | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 interp/output_buffer_cyber3_vuln_hunt_test.go diff --git a/interp/output_buffer_cyber3_vuln_hunt_test.go b/interp/output_buffer_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..5fecfe55 --- /dev/null +++ b/interp/output_buffer_cyber3_vuln_hunt_test.go @@ -0,0 +1,186 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: output-buffer-1mb-limit (subsystem) + +package interp + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeOutputBufferMiBFile(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "mb.txt"), []byte(strings.Repeat("A", 1<<20)), 0o644)) +} + +func TestVulnHuntSubsystemOutputBuffer_ConcurrentPipelineStderrCapped(t *testing.T) { + dir := t.TempDir() + writeOutputBufferMiBFile(t, dir) + + var stdout, stderr bytes.Buffer + runner, err := New( + StdIO(nil, &stdout, &stderr), + AllowedPaths([]string{dir}), + allowAllCommandsOpt(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + runner.Dir = dir + + script := `for i in 1 2 3 4 5 6 7 8 9 10 11; do cat mb.txt >&2; done | for i in 1 2 3 4 5 6 7 8 9 10 11; do cat mb.txt >&2; done` + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + err = runner.Run(ctx, parseScript(t, script)) + require.ErrorIs(t, err, ErrStderrLimitExceeded) + assert.NotErrorIs(t, err, ErrOutputLimitExceeded) + assert.Empty(t, stdout.String()) + assert.LessOrEqual(t, stderr.Len(), maxStderrBytes) + assert.Greater(t, stderr.Len(), 0) +} + +func TestVulnHuntSubsystemOutputBuffer_WritersRestoreAfterExceededRun(t *testing.T) { + dir := t.TempDir() + writeOutputBufferMiBFile(t, dir) + + var stdout, stderr bytes.Buffer + runner, err := New( + StdIO(nil, &stdout, &stderr), + AllowedPaths([]string{dir}), + allowAllCommandsOpt(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + runner.Dir = dir + + err = runner.Run(context.Background(), parseScript(t, `for i in 1 2 3 4 5 6 7 8 9 10 11; do cat mb.txt; done`)) + require.ErrorIs(t, err, ErrOutputLimitExceeded) + assert.Equal(t, int64(maxStdoutBytes), int64(stdout.Len())) + + stdout.Reset() + stderr.Reset() + err = runner.Run(context.Background(), parseScript(t, `echo after`)) + require.NoError(t, err) + assert.Equal(t, "after\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntSubsystemOutputBuffer_DevNullRedirectDoesNotCountAsCallerOutput(t *testing.T) { + dir := t.TempDir() + writeOutputBufferMiBFile(t, dir) + + var stdout, stderr bytes.Buffer + runner, err := New( + StdIO(nil, &stdout, &stderr), + AllowedPaths([]string{dir}), + allowAllCommandsOpt(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + runner.Dir = dir + + script := `for i in 1 2 3 4 5 6 7 8 9 10 11; do cat mb.txt > ` + os.DevNull + `; done; echo after` + err = runner.Run(context.Background(), parseScript(t, script)) + require.NoError(t, err) + assert.Equal(t, "after\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntSubsystemOutputBuffer_CommandSubstitutionCaptureStaysAtOneMiB(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.txt"), []byte(strings.Repeat("B", maxCmdSubstOutput+100)), 0o644)) + + var stdout, stderr bytes.Buffer + runner, err := New( + StdIO(nil, &stdout, &stderr), + AllowedPaths([]string{dir}), + allowAllCommandsOpt(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + runner.Dir = dir + + err = runner.Run(context.Background(), parseScript(t, `x=$(&2; done; exit 7` + err = runner.Run(context.Background(), parseScript(t, script)) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOutputLimitExceeded)) + assert.True(t, errors.Is(err, ErrStderrLimitExceeded)) + var status ExitStatus + assert.False(t, errors.As(err, &status)) + assert.LessOrEqual(t, stdout.Len(), maxStdoutBytes) + assert.LessOrEqual(t, stderr.Len(), maxStderrBytes) +} From a8df567e5e8fdb45fdcbff5806e939f4aba5b41a Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 09:33:28 +0200 Subject: [PATCH 72/73] test: add var expansion vuln hunt tripwires --- interp/var_expand_cyber3_vuln_hunt_test.go | 307 +++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 interp/var_expand_cyber3_vuln_hunt_test.go diff --git a/interp/var_expand_cyber3_vuln_hunt_test.go b/interp/var_expand_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..a71b064d --- /dev/null +++ b/interp/var_expand_cyber3_vuln_hunt_test.go @@ -0,0 +1,307 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// vuln-hunt campaign: 2026-05-20-gpt-5.5-cyber-3 +// Target: var_expand (shell-feature) + +package interp + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/expand" +) + +func runVarExpandCyber3(t *testing.T, script string, dir string, opts ...RunnerOption) (string, string, int, error) { + t.Helper() + + prog, err := ParseScript(script, "var_expand_cyber3_vuln_hunt.sh") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + allOpts := append([]RunnerOption{StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()}, opts...) + r, err := New(allOpts...) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + if dir != "" { + r.Dir = dir + } + + err = r.Run(context.Background(), prog) + code := 0 + if err != nil { + var status ExitStatus + if errors.As(err, &status) { + code = int(status) + err = nil + } + } + return stdout.String(), stderr.String(), code, err +} + +func TestVulnHuntShellFeatureExpansionChain_VariableAndCmdSubstValuesRemainData(t *testing.T) { + stdout, stderr, code, err := runVarExpandCyber3(t, `EVIL='; echo HACKED ;' +payload=$(printf 'echo SAFE; echo HACKED') +echo a $EVIL b +$payload +echo '$(echo literal)' +`, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "a ; echo HACKED ; b\nSAFE; echo HACKED\n$(echo literal)\n", stdout) + assert.Empty(t, stderr) + assert.NotContains(t, stdout, "\nHACKED\n") +} + +func TestVulnHuntShellFeatureExpansionChain_GlobFromVariableStaysSandboxedAndBounded(t *testing.T) { + root := t.TempDir() + allowed := filepath.Join(root, "allowed") + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(allowed, 0o755)) + require.NoError(t, os.Mkdir(outside, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(allowed, "safe.txt"), []byte("safe\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret\n"), 0o644)) + + stdout, stderr, code, err := runVarExpandCyber3(t, `PATTERN='../outside/*' +cat $PATTERN +echo status=$? +cat safe.txt +`, allowed, AllowedPaths([]string{allowed})) + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "status=1\nsafe\n", stdout) + assert.Contains(t, stderr, "permission denied") + assert.NotContains(t, stdout, "secret") + + args := make([]string, MaxGlobReadDirCalls+1) + for i := range args { + args[i] = fmt.Sprintf("nomatch_%d_*", i) + } + stdout, stderr, code, err = runVarExpandCyber3(t, "echo "+strings.Join(args, " ")+"\n", allowed, AllowedPaths([]string{allowed})) + require.NoError(t, err) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "glob expansion exceeded maximum number of directory reads") +} + +func TestVulnHuntShellFeatureParserConfusion_ValidationPrecedesExpansionSideEffects(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + + require.NoError(t, r.Run(context.Background(), parseScript(t, "A=original\n"))) + + err = r.Run(context.Background(), parseScript(t, "A=mutated echo ${A:=fallback}\necho after\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "operations (defaults, pattern removal, case conversion) are not supported") + + stdout.Reset() + stderr.Reset() + require.NoError(t, r.Run(context.Background(), parseScript(t, "echo $A\n"))) + assert.Equal(t, "original\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureParserConfusion_SpecialVariablesAndOddBytesStayControlled(t *testing.T) { + stdout, stderr, code, err := runVarExpandCyber3(t, "V=ok\necho \"[$VÀR]\"\necho \"$(printf 'before\\x00after')\"\n", "") + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "[okÀR]\nbeforeafter\n", stdout) + assert.Empty(t, stderr) + + stdout, stderr, code, err = runVarExpandCyber3(t, "A=mutated\necho $LINENO\necho after\n", "") + require.NoError(t, err) + assert.Equal(t, 2, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "$LINENO is not supported") +} + +func TestVulnHuntShellFeatureSubshellIsolation_AssignmentsAndStatusDoNotLeak(t *testing.T) { + stdout, stderr, code, err := runVarExpandCyber3(t, `A=parent +false +captured=$(A=child; true; echo "$A:$?") +echo "captured=$captured parent=$A status=$?" +( A=subshell; B=inside; echo "sub=$A/$B" ) +echo "parent=$A/$B" +echo left | { A=pipe; cat >/dev/null; } +echo "after_pipe=$A" +`, "") + + require.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "captured=child:0 parent=parent status=0\nsub=subshell/inside\nparent=parent/\nafter_pipe=parent\n", stdout) + assert.Empty(t, stderr) +} + +func TestVulnHuntShellFeatureDeclaredVsImplemented_VariableAndCommandSubstitutionCaps(t *testing.T) { + stdout, stderr, code, err := runVarExpandCyber3(t, `A=x +A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A +A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A;A=$A$A +B=$A +C=x +echo DONE +`, "") + require.NoError(t, err) + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "C: variable storage limit exceeded (1048577 bytes total)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.txt"), []byte(strings.Repeat("A", (1<<20)+100)), 0o644)) + stdout, stderr, code, err = runVarExpandCyber3(t, `x=$( $DEVNULL\necho after\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "> file redirection is not supported") + + stdout.Reset() + stderr.Reset() + require.NoError(t, r.Run(context.Background(), parseScript(t, "echo $X\n"))) + assert.Equal(t, "original\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureReadonlyBypass_AssignmentPathsRespectReadonly(t *testing.T) { + var stdout, stderr bytes.Buffer + r, err := New(StdIO(nil, &stdout, &stderr), allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { _ = r.Close() }) + r.Reset() + require.NoError(t, r.writeEnv.Set("RO", expand.Variable{ + Set: true, + Kind: expand.String, + Str: "original", + ReadOnly: true, + })) + + err = r.Run(context.Background(), parseScript(t, "RO=changed\necho ro=$RO\n")) + require.NoError(t, err) + assert.Equal(t, "ro=original\n", stdout.String()) + assert.Contains(t, stderr.String(), "RO: readonly variable") + + stdout.Reset() + stderr.Reset() + err = r.Run(context.Background(), parseScript(t, "echo ${RO:=changed}\n")) + var status ExitStatus + require.ErrorAs(t, err, &status) + assert.Equal(t, ExitStatus(2), status) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "operations (defaults, pattern removal, case conversion) are not supported") + + stdout.Reset() + stderr.Reset() + require.NoError(t, r.Run(context.Background(), parseScript(t, "echo ro=$RO\n"))) + assert.Equal(t, "ro=original\n", stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntShellFeatureSignalContext_ExpansionLoopsRespectCancellation(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644)) + + r := newTimeoutRunner(t, StdIO(nil, &bytes.Buffer{}, &bytes.Buffer{}), AllowedPaths([]string{dir}), MaxExecutionTime(60*time.Millisecond)) + r.Dir = dir + start := time.Now() + err := r.Run(context.Background(), parseScript(t, "while true; do PAT='*.txt'; echo $PAT >/dev/null; done\n")) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 5*time.Second) + + r2 := newTimeoutRunner(t, StdIO(nil, &bytes.Buffer{}, &bytes.Buffer{}), MaxExecutionTime(60*time.Millisecond)) + start = time.Now() + err = r2.Run(context.Background(), parseScript(t, "x=$(while true; do echo x; done)\necho done\n")) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(start), 5*time.Second) +} From 710b0939ca445bda75b4bfec556bb0f64766bdd5 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Thu, 21 May 2026 09:38:22 +0200 Subject: [PATCH 73/73] test: add true vuln hunt tripwires --- .../tests/true/true_cyber3_vuln_hunt_test.go | 109 ++++++++++++++++++ builtins/true/true_cyber3_vuln_hunt_test.go | 69 +++++++++++ 2 files changed, 178 insertions(+) create mode 100644 builtins/tests/true/true_cyber3_vuln_hunt_test.go create mode 100644 builtins/true/true_cyber3_vuln_hunt_test.go diff --git a/builtins/tests/true/true_cyber3_vuln_hunt_test.go b/builtins/tests/true/true_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..c689f4c7 --- /dev/null +++ b/builtins/tests/true/true_cyber3_vuln_hunt_test.go @@ -0,0 +1,109 @@ +package true_test + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp" +) + +func runScript(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + + prog, err := syntax.NewParser().Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + runner, err := interp.New(append([]interp.RunnerOption{ + interp.StdIO(nil, &stdout, &stderr), + }, opts...)...) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + err = runner.Run(context.Background(), prog) + code := 0 + if err != nil { + var exit interp.ExitStatus + if errors.As(err, &exit) { + code = int(exit) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + + return stdout.String(), stderr.String(), code +} + +func TestVulnHuntBuiltinTrueDoesNotReadBlockedStdin(t *testing.T) { + stdin, writer, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + t.Cleanup(func() { _ = writer.Close() }) + + prog, err := syntax.NewParser().Parse(strings.NewReader("true\n"), "") + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + runner, err := interp.New( + interp.StdIO(stdin, &stdout, &stderr), + interp.AllowedCommands([]string{"rshell:true"}), + interp.MaxExecutionTime(25*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + start := time.Now() + err = runner.Run(context.Background(), prog) + elapsed := time.Since(start) + + require.NoError(t, err) + assert.Less(t, elapsed, 500*time.Millisecond) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) +} + +func TestVulnHuntBuiltinTrueHelpMetadataDoesNotExecuteTrue(t *testing.T) { + stdout, stderr, code := runScript(t, "true --help\necho true_status=$?\nhelp true\necho help_status=$?\n", + interp.AllowedCommands([]string{"rshell:true", "rshell:help", "rshell:echo"})) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "true_status=0\n") + assert.Contains(t, stdout, "true: true\n") + assert.Contains(t, stdout, "help_status=0\n") + assert.NotContains(t, stdout, "Usage:") +} + +func TestVulnHuntBuiltinTrueCommandPolicyGatesDispatch(t *testing.T) { + stdout, stderr, code := runScript(t, "true\necho status=$?\n", + interp.AllowedCommands([]string{"rshell:echo", "rshell:help"})) + + assert.Equal(t, 0, code) + assert.Equal(t, "status=127\n", stdout) + assert.Contains(t, stderr, "rshell: true: command not allowed") + assert.Contains(t, stderr, "Run 'help' to see allowed commands.") +} + +func TestVulnHuntBuiltinTrueDoesNotMutateShellState(t *testing.T) { + stdout, stderr, code := runScript(t, `X=parent +X=inline true +echo after_inline=$X +( X=subshell; true; echo sub=$X ) +echo parent=$X +for X in a b; do true; done +echo loop=$X +`, interp.AllowedCommands([]string{"rshell:true", "rshell:echo"})) + + assert.Equal(t, 0, code) + assert.Empty(t, stderr) + assert.Equal(t, "after_inline=parent\nsub=subshell\nparent=parent\nloop=b\n", stdout) +} diff --git a/builtins/true/true_cyber3_vuln_hunt_test.go b/builtins/true/true_cyber3_vuln_hunt_test.go new file mode 100644 index 00000000..31f7ff82 --- /dev/null +++ b/builtins/true/true_cyber3_vuln_hunt_test.go @@ -0,0 +1,69 @@ +package truecmd + +import ( + "context" + "strings" + "sync" + "testing" +) + +func TestVulnHuntBuiltinTruePureStatusIgnoresAllArguments(t *testing.T) { + cases := [][]string{ + nil, + {}, + {"--help"}, + {"-h"}, + {"--unknown"}, + {"--"}, + {"--", "--help"}, + {"name\nFORGED_TRUE_ROW=1"}, + {"$(echo should-not-run)", ";", "echo", "pwned"}, + {strings.Repeat("A", 1<<20)}, + } + + for _, args := range cases { + result := run(context.Background(), nil, args) + if result.Code != 0 { + t.Fatalf("true(%q) Code = %d, want 0", args, result.Code) + } + if result.Exiting || result.BreakN != 0 || result.ContinueN != 0 { + t.Fatalf("true(%q) produced control-flow result: %+v", args, result) + } + } +} + +func TestVulnHuntBuiltinTrueCanceledContextStillHasNoIOSurface(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := run(ctx, nil, []string{"--help", "ignored"}) + if result.Code != 0 { + t.Fatalf("true with canceled context Code = %d, want 0", result.Code) + } + if result.Exiting || result.BreakN != 0 || result.ContinueN != 0 { + t.Fatalf("true with canceled context produced control-flow result: %+v", result) + } +} + +func TestVulnHuntBuiltinTrueConcurrentRunsDoNotShareState(t *testing.T) { + const workers = 64 + + var wg sync.WaitGroup + errs := make(chan string, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + result := run(context.Background(), nil, []string{"--help", "ignored"}) + if result.Code != 0 || result.Exiting || result.BreakN != 0 || result.ContinueN != 0 { + errs <- "unexpected result" + } + }() + } + wg.Wait() + close(errs) + + for err := range errs { + t.Fatal(err) + } +}