Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ff58afe
Add sort builtin command with key sorting, field separation, and safe…
matt-dz Mar 11, 2026
5cd5a4e
Fix sort review findings: stable sort, -b blanks, per-file -c, flag r…
matt-dz Mar 11, 2026
b7d0e30
Merge branch 'main' into matt-dz/implement-sort-builtin
matt-dz Mar 11, 2026
57cc6af
Fix codex review findings: -u dedup, -c -u strict check, decimal -n, …
matt-dz Mar 11, 2026
ce7822f
Fix codex findings: -c multi-file reject, byte cap in scanner, --chec…
matt-dz Mar 11, 2026
1135fca
Address codex review: separator preservation, string numeric cmp, --c…
matt-dz Mar 11, 2026
f60dd25
Address codex review: reverse tie-break, non-numeric zero, blank-fiel…
matt-dz Mar 11, 2026
2c42618
Address codex review: +sign, .5 decimal, empty tab, key validation, CRLF
matt-dz Mar 11, 2026
8a5407f
Add regression tests for codex findings and fix -k end-field validation
matt-dz Mar 11, 2026
2798ef4
Address codex review: blank-field preservation, char offset validatio…
matt-dz Mar 12, 2026
230bcc7
Address codex review: stable sort for -u, trailing blank field preser…
matt-dz Mar 12, 2026
7f9b025
Fix -k end-before-start key ranges to produce zero-width keys (GNU so…
matt-dz Mar 12, 2026
c1259db
Add unit tests for splitBlankFields and extractKey internals
matt-dz Mar 12, 2026
f06811f
Fix out-of-range end field, reject incompatible -dn and -cC flags
matt-dz Mar 12, 2026
775333e
Fix fmt.Errorf usage to pass allowed symbols check
matt-dz Mar 12, 2026
093bc4d
Fix key extraction to use byte positions and apply -b during extraction
matt-dz Mar 12, 2026
e5b15f1
Fix three GNU sort compatibility issues in key parsing and extraction
matt-dz Mar 12, 2026
cee56dd
Fix -b flag handling: separate start/end blank-skipping, no double-trim
matt-dz Mar 12, 2026
58d0208
Merge branch 'main' into matt-dz/implement-sort-builtin
matt-dz Mar 12, 2026
f1053de
Add ctx check in checkSorted loop, allow -C with --check=silent
matt-dz Mar 12, 2026
d32c477
Defer -d/-n conflict check, detect mixed --check modes
matt-dz Mar 12, 2026
23849b5
Reject --check= (empty value) as invalid argument
matt-dz Mar 12, 2026
6de2ca0
Fix end-before-start keys with char offsets, -b past empty fields
matt-dz Mar 12, 2026
eb65ce0
Improve sort cancellation and fix check-mode read error exit code
matt-dz Mar 12, 2026
9ac13bf
run gofmt
matt-dz Mar 12, 2026
8dac5ad
Merge branch 'main' into matt-dz/implement-sort-builtin
matt-dz Mar 12, 2026
300c8ac
run gofmt
matt-dz Mar 12, 2026
52d54d4
Uncap end-position -b blank-skip loop to len(line)
matt-dz Mar 12, 2026
9b88ba9
Replace TrimSpace with blank-only skip in parseNumParts
matt-dz Mar 12, 2026
c0def0e
Add scenario test for -n treating vertical tab as non-numeric
matt-dz Mar 12, 2026
272940d
Add missing scenario tests for --check= rejection and -c read error
matt-dz Mar 12, 2026
c45cd5d
Add Go tests for checkSorted context cancellation
matt-dz Mar 12, 2026
5f36705
Move skip_assert_against_bash to top level in 7 scenario files
matt-dz Mar 12, 2026
2c2a710
Remove dead extractKeyFromFields, fix blank-skip reslicing
matt-dz Mar 12, 2026
402e7ee
Allow fmt.Errorf in builtin allowlist, use it in sort
matt-dz Mar 12, 2026
b85def4
Add ctx check in dedup loop, fix scanner off-by-one for max lines
matt-dz Mar 12, 2026
9f5bdf6
Merge branch 'main' into matt-dz/implement-sort-builtin
matt-dz Mar 13, 2026
72c5284
Check context at start of checkSorted for small inputs
matt-dz Mar 13, 2026
3d0e878
Simplify ctx cancellation: check every iteration, not every 1024th
matt-dz Mar 13, 2026
bc62e1e
Add note explaining why cancellation is unit-tested, not integration-…
matt-dz Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Blocked features are rejected before execution with exit code 2.
- ✅ `false` — return exit code 1
- ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking)
- ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected
- ✅ `sort [-rnubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec)
- ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory
- ✅ `printf FORMAT [ARGUMENT]...` — format and print data to stdout; supports `%s`, `%b`, `%c`, `%d`, `%i`, `%o`, `%u`, `%x`, `%X`, `%e`, `%E`, `%f`, `%F`, `%g`, `%G`, `%%`; format reuse for excess arguments; `%n` rejected (security risk); `-v` rejected
- ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked
Expand Down
225 changes: 225 additions & 0 deletions interp/builtins/sort/builtin_sort_pentest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// 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.

// Exploratory pentest for the sort builtin.
//
// These tests probe rejected flags, memory safety, path edge cases,
// and flag injection scenarios. Tests that might hang are run in a
// goroutine with time.After to bound execution.

package sort_test

import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/DataDog/rshell/interp"
)

const pentestTimeout = 10 * time.Second

// sortRun is a shorthand for runScript with AllowedPaths=dir.
func sortRun(t *testing.T, script, dir string, extraPaths ...string) (stdout, stderr string, exitCode int) {
t.Helper()
paths := append([]string{dir}, extraPaths...)
return runScript(t, script, dir, interp.AllowedPaths(paths))
}

// mustNotHang runs f in a goroutine and fails the test if it does not return
// within pentestTimeout.
func mustNotHang(t *testing.T, f func()) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
f()
}()
select {
case <-done:
case <-time.After(pentestTimeout):
t.Fatalf("operation did not complete within %s", pentestTimeout)
}
}

// --- Rejected flags (GTFOBins vectors) ---

func TestCmdPentestOutputFlagRejected(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, "sort -o out.txt f.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
// Verify no file was created.
_, err := os.Stat(filepath.Join(dir, "out.txt"))
assert.True(t, os.IsNotExist(err))
}

func TestCmdPentestOutputFlagLong(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, "sort --output=out.txt f.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

func TestCmdPentestCompressProgramRejected(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, "sort --compress-program=sh f.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

func TestCmdPentestTempDirRejected(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, "sort --temporary-directory=/tmp f.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

// --- Path traversal ---

func TestCmdPentestPathTraversal(t *testing.T) {
dir := t.TempDir()
_, stderr, code := sortRun(t, "sort ../../etc/passwd", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

func TestCmdPentestOutsideSandbox(t *testing.T) {
allowed := t.TempDir()
secret := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(secret, "s.txt"), []byte("secret"), 0644))
secretPath := filepath.ToSlash(filepath.Join(secret, "s.txt"))
_, stderr, code := runScript(t, "sort "+secretPath, allowed, interp.AllowedPaths([]string{allowed}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

// --- Nonexistent and empty files ---

func TestCmdPentestNonexistentFile(t *testing.T) {
dir := t.TempDir()
_, stderr, code := sortRun(t, "sort does_not_exist.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

func TestCmdPentestEmptyFilename(t *testing.T) {
dir := t.TempDir()
_, _, code := sortRun(t, `sort ""`, dir)
assert.Equal(t, 1, code)
}

func TestCmdPentestDirectoryAsFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755))
_, stderr, code := sortRun(t, "sort subdir", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

// --- Memory safety ---

func TestCmdPentestLargeFile(t *testing.T) {
// A file with 10000 lines should sort without hanging.
dir := t.TempDir()
var buf bytes.Buffer
for i := 10000; i > 0; i-- {
buf.WriteString(fmt.Sprintf("%d\n", i))
}
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.txt"), buf.Bytes(), 0644))
mustNotHang(t, func() {
stdout, _, code := sortRun(t, "sort -n big.txt", dir)
assert.Equal(t, 0, code)
lines := strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")
assert.Equal(t, 10000, len(lines))
assert.Equal(t, "1", lines[0])
assert.Equal(t, "10000", lines[len(lines)-1])
})
}

func TestCmdPentestLongLine(t *testing.T) {
// A line just below the 1 MiB cap should succeed.
dir := t.TempDir()
line := bytes.Repeat([]byte("a"), 1<<20-2)
line = append(line, '\n')
require.NoError(t, os.WriteFile(filepath.Join(dir, "long.txt"), line, 0644))
mustNotHang(t, func() {
stdout, _, code := sortRun(t, "sort long.txt", dir)
assert.Equal(t, 0, code)
assert.Equal(t, string(line), stdout)
})
}

func TestCmdPentestLongLineExceedsCap(t *testing.T) {
// A line exceeding the 1 MiB cap should error, not crash.
dir := t.TempDir()
content := bytes.Repeat([]byte("a"), 1<<20+1)
require.NoError(t, os.WriteFile(filepath.Join(dir, "huge.txt"), content, 0644))
mustNotHang(t, func() {
_, stderr, code := sortRun(t, "sort huge.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
})
}

// --- Flag injection ---

func TestCmdPentestFlagViaExpansion(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, `flag="--output=evil.txt"; sort $flag f.txt`, dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
// Verify no file was created.
_, err := os.Stat(filepath.Join(dir, "evil.txt"))
assert.True(t, os.IsNotExist(err))
}

func TestCmdPentestUnknownLongFlag(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, "sort --no-such-flag f.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

func TestCmdPentestUnknownShortFlag(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "f.txt", "hello\n")
_, stderr, code := sortRun(t, "sort -Z f.txt", dir)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "sort:")
}

// --- Double dash ---

func TestCmdPentestFlagLikeName(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "-r"), []byte("flag-file\n"), 0644))
stdout, _, code := sortRun(t, "sort -- -r", dir)
assert.Equal(t, 0, code)
assert.Equal(t, "flag-file\n", stdout)
}

// --- Nil stdin ---

func TestCmdPentestNilStdin(t *testing.T) {
dir := t.TempDir()
stdout, stderr, code := runScript(t, "sort -", dir, interp.AllowedPaths([]string{dir}))
assert.Equal(t, 0, code)
assert.Equal(t, "", stdout)
assert.Equal(t, "", stderr)
}
79 changes: 79 additions & 0 deletions interp/builtins/sort/cancellation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

package sort

import (
"bytes"
"context"
"strings"
"testing"

"github.com/DataDog/rshell/interp/builtins"
"github.com/stretchr/testify/assert"
)

func TestCheckSortedRespectsContextCancellation(t *testing.T) {
// A cancelled context should be detected on the first iteration,
// even for small inputs.
lines := []string{"a", "b", "c"}

ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately

var stderr bytes.Buffer
callCtx := &builtins.CallContext{
Stdout: &bytes.Buffer{},
Stderr: &stderr,
}

cmpFn := func(a, b string) int {
return strings.Compare(a, b)
}

result := checkSorted(ctx, callCtx, lines, cmpFn, false, false, "-")

assert.Equal(t, uint8(1), result.Code,
"checkSorted should return exit code 1 when context is cancelled")
}

func TestCheckSortedCompletesWithoutCancellation(t *testing.T) {
// Verify that checkSorted works normally without cancellation.
lines := []string{"a", "b", "c"}

var stderr bytes.Buffer
callCtx := &builtins.CallContext{
Stdout: &bytes.Buffer{},
Stderr: &stderr,
}

cmpFn := func(a, b string) int {
return strings.Compare(a, b)
}

result := checkSorted(context.Background(), callCtx, lines, cmpFn, false, false, "-")

assert.Equal(t, uint8(0), result.Code,
"checkSorted should return exit code 0 for sorted input")
}

func TestCheckSortedDetectsDisorder(t *testing.T) {
lines := []string{"b", "a", "c"}

var stderr bytes.Buffer
callCtx := &builtins.CallContext{
Stdout: &bytes.Buffer{},
Stderr: &stderr,
}

cmpFn := func(a, b string) int {
return strings.Compare(a, b)
}

result := checkSorted(context.Background(), callCtx, lines, cmpFn, false, false, "-")

assert.Equal(t, uint8(1), result.Code)
assert.Contains(t, stderr.String(), "disorder")
}
Loading
Loading