diff --git a/allowedpaths/sandbox_vuln_hunt_test.go b/allowedpaths/sandbox_vuln_hunt_test.go new file mode 100644 index 000000000..c30f00c80 --- /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 +} 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 000000000..69681bdf7 --- /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 +} diff --git a/analysis/callctx_openfile_vuln_hunt_test.go b/analysis/callctx_openfile_vuln_hunt_test.go new file mode 100644 index 000000000..941d399f6 --- /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 "" + } +} 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 000000000..f43f45f9c --- /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/analysis/ss_procnet_readers_vuln_hunt_test.go b/analysis/ss_procnet_readers_vuln_hunt_test.go new file mode 100644 index 000000000..311151b2f --- /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/break/break_vuln_hunt_test.go b/builtins/break/break_vuln_hunt_test.go new file mode 100644 index 000000000..e6d76e222 --- /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()) +} diff --git a/builtins/cat/cat_vuln_hunt_test.go b/builtins/cat/cat_vuln_hunt_test.go new file mode 100644 index 000000000..006b43cf5 --- /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) +} diff --git a/builtins/cd/cd_vuln_hunt_test.go b/builtins/cd/cd_vuln_hunt_test.go new file mode 100644 index 000000000..ace60cedf --- /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), "'", `'\''`) + "'" +} 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 000000000..849f32a65 --- /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 < 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) + } +} 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 000000000..7b7fa9147 --- /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 000000000..9199bbdfb --- /dev/null +++ b/builtins/internal/procnetsocket/procnetsocket_vuln_hunt_linux_test.go @@ -0,0 +1,55 @@ +// 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" + "strings" + "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") +} + +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") +} 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 000000000..6fad062ce --- /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") + }) + } +} diff --git a/builtins/ip/ip_vuln_hunt_test.go b/builtins/ip/ip_vuln_hunt_test.go new file mode 100644 index 000000000..a2c59bdc9 --- /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/ls/ls_vuln_hunt_test.go b/builtins/ls/ls_vuln_hunt_test.go new file mode 100644 index 000000000..f49532337 --- /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 000000000..d218cbe51 --- /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|") + }) +} diff --git a/builtins/ping/ping_vuln_hunt_test.go b/builtins/ping/ping_vuln_hunt_test.go new file mode 100644 index 000000000..6e545af76 --- /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") +} 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 000000000..28f4592fa --- /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) +} 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 000000000..debf6b64b --- /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") +} diff --git a/builtins/pwd/pwd_vuln_hunt_test.go b/builtins/pwd/pwd_vuln_hunt_test.go index a9520f905..e9cb133cd 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:") +} diff --git a/builtins/sort/sort_vuln_hunt_test.go b/builtins/sort/sort_vuln_hunt_test.go new file mode 100644 index 000000000..66c3eccfd --- /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 000000000..c453fc9f6 --- /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") + }) +} 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 000000000..ff96dd528 --- /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 000000000..aad617be4 --- /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 000000000..f19f9dad7 --- /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()) +} 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 000000000..97a029747 --- /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) +} diff --git a/builtins/tail/tail_vuln_hunt_test.go b/builtins/tail/tail_vuln_hunt_test.go new file mode 100644 index 000000000..7e015595c --- /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) +} diff --git a/builtins/testcmd/testcmd_vuln_hunt_test.go b/builtins/testcmd/testcmd_vuln_hunt_test.go new file mode 100644 index 000000000..0f7039e13 --- /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 000000000..cd87bc77d --- /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) +} 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 000000000..c8496ee8f --- /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:") +} 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 000000000..aa3476a0b --- /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") +} 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 000000000..bcc9545b2 --- /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 000000000..fb04df611 --- /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) + }) + } +} 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 000000000..c689f4c7f --- /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/tests/uname/uname_vuln_hunt_linux_test.go b/builtins/tests/uname/uname_vuln_hunt_linux_test.go new file mode 100644 index 000000000..432c94f3a --- /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) +} 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 000000000..cf51132b1 --- /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) +} diff --git a/builtins/tr/tr_vuln_hunt_test.go b/builtins/tr/tr_vuln_hunt_test.go new file mode 100644 index 000000000..5f29568d4 --- /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) +} 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 000000000..31f7ff82c --- /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) + } +} diff --git a/builtins/uniq/uniq_vuln_hunt_test.go b/builtins/uniq/uniq_vuln_hunt_test.go new file mode 100644 index 000000000..70f5e3aca --- /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) +} diff --git a/builtins/xargs/xargs_vuln_hunt_test.go b/builtins/xargs/xargs_vuln_hunt_test.go index 7a089598e..5131051b2 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") +} 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 000000000..16f16b071 --- /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) +} 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 000000000..1df809639 --- /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/allowed_commands_vuln_hunt_test.go b/interp/allowed_commands_vuln_hunt_test.go new file mode 100644 index 000000000..e56d9aa96 --- /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=$(