diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b78ada7e..71ec15a2 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -56,6 +56,13 @@ jobs: - pkg: ./builtins/tests/ss/ name: ss corpus_path: builtins/tests/ss + - pkg: ./builtins/ping/ + name: ping + # ping fuzz tests live in builtins/ping/ rather than builtins/tests/ping/ + # because they share test helper functions (runScriptCtx, etc.) defined in + # ping_test.go. Go test helpers are only in scope within the same directory, + # so both files must reside in builtins/ping/. + corpus_path: builtins/ping - pkg: ./interp/tests/ name: interp corpus_path: interp/tests diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 177c937a..d3485289 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -4,7 +4,11 @@ gopkg.in/yaml.v3,https://github.com/go-yaml/yaml,MIT AND Apache-2.0,"Copyright ( mvdan.cc/sh/v3,https://github.com/mvdan/sh,BSD-3-Clause,"Copyright (c) 2016, Daniel Marti" github.com/davecgh/go-spew,https://github.com/davecgh/go-spew,ISC,Copyright (c) 2012-2016 Dave Collins github.com/inconshreveable/mousetrap,https://github.com/inconshreveable/mousetrap,Apache-2.0,Copyright 2014 Alan Shreve +github.com/google/uuid,https://github.com/google/uuid,BSD-3-Clause,Copyright (c) 2018 Google Inc. github.com/pmezard/go-difflib,https://github.com/pmezard/go-difflib,BSD-3-Clause,"Copyright (c) 2013, Patrick Mezard" +github.com/prometheus-community/pro-bing,https://github.com/prometheus-community/pro-bing,MIT,"Copyright 2022 The Prometheus Authors; Copyright 2016 Cameron Sparr and contributors" github.com/spf13/cobra,https://github.com/spf13/cobra,Apache-2.0,Copyright 2013-2023 The Cobra Authors github.com/spf13/pflag,https://github.com/spf13/pflag,BSD-3-Clause,"Copyright (c) 2012 Alex Ogier, The Go Authors" +golang.org/x/net,https://github.com/golang/net,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. +golang.org/x/sync,https://github.com/golang/sync,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/sys,https://github.com/golang/sys,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 64563a44..ef0677e3 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -20,6 +20,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `sort [-rnubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly (Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll); `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected - ✅ `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 +- ✅ `ping [-c N] [-W DURATION] [-i DURATION] [-q] [-4|-6] [-h] HOST` — send ICMP echo requests to a network host and report round-trip statistics; `-f` (flood), `-b` (broadcast), `-s` (packet size), `-I` (interface), `-p` (pattern), and `-R` (record route) are blocked; count/wait/interval are clamped to safe ranges with a warning; multicast, unspecified (`0.0.0.0`/`::`), and broadcast addresses (IPv4 last-octet `.255`) are rejected — note: directed broadcasts on non-standard subnets (e.g. `.127` on a `/25`) are not blocked without subnet-mask knowledge - ✅ `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 - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` diff --git a/allowedsymbols/symbols_builtins.go b/allowedsymbols/symbols_builtins.go index 87e0a39c..a4434bc9 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -319,6 +319,35 @@ var builtinPerCommandSymbols = map[string][]string{ "unicode/utf8.UTFMax", // maximum number of bytes in a UTF-8 encoding; constant, no I/O. "unicode/utf8.Valid", // checks if a byte slice is valid UTF-8; pure function, no I/O. }, + "ping": { + "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. + "context.WithTimeout", // creates a child context with a deadline; no filesystem or network I/O itself. + "errors.Is", // error comparison via chain; pure function, no I/O. + "fmt.Errorf", // error formatting; pure function, no I/O. + "fmt.Sprintf", // string formatting; pure function, no I/O. + "net.DefaultResolver", // default system DNS resolver; used for context-aware address lookup; network I/O is the explicit purpose of this builtin. + "net.IPAddr", // resolved IP address struct (IP + Zone); pure data type, no I/O. + "net.ParseIP", // parses an IP address string; pure function, no I/O. + "math.IsInf", // IEEE 754 infinity check; pure function, no I/O. + "math.IsNaN", // IEEE 754 NaN check; pure function, no I/O. + "math.MaxInt64", // maximum int64 constant; used to compute time.Duration overflow boundary. + "strconv.ParseFloat", // parses integer/float seconds for -W/-i flags; pure function, no I/O. + "strings.Contains", // substring search; pure function, no I/O. + "strings.IndexByte", // finds first occurrence of a byte in a string; pure function, no I/O. + "strings.ToLower", // converts string to lowercase; pure function, no I/O. + "syscall.EACCES", // POSIX errno constant for permission denied; pure constant, no I/O. + "syscall.EPERM", // POSIX errno constant for operation not permitted; pure constant, no I/O. + "syscall.EPROTONOSUPPORT", // POSIX errno constant for protocol not supported; pure constant, no I/O. + "time.Duration", // duration type alias (int64 nanoseconds); pure type, no I/O. + "time.Millisecond", // constant representing one millisecond; no side effects. + "time.ParseDuration", // parses Go duration strings (e.g. "1s"); pure function, no I/O. + "time.Second", // constant representing one second; no side effects. + "github.com/prometheus-community/pro-bing.NewPinger", // creates an ICMP pinger; network I/O is the explicit purpose of this builtin. + "github.com/prometheus-community/pro-bing.NoopLogger", // no-op logger that discards pro-bing internal messages; no side effects. + "github.com/prometheus-community/pro-bing.Packet", // ICMP packet descriptor struct (received packet data); pure data type, no I/O. + "github.com/prometheus-community/pro-bing.Pinger", // ICMP pinger struct; network I/O is the explicit purpose of this builtin. + "github.com/prometheus-community/pro-bing.Statistics", // ping round-trip statistics struct; pure data type, no I/O. + }, "ip": { "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. "fmt.Errorf", // error formatting; pure function, no I/O. @@ -339,117 +368,134 @@ var builtinPerCommandSymbols = map[string][]string{ } var builtinAllowedSymbols = []string{ - "bufio.NewScanner", // line-by-line input reading (e.g. head, cat); no write or exec capability. - "bufio.Scanner", // scanner type for buffered input reading; no write or exec capability. - "bufio.SplitFunc", // type for custom scanner split functions; pure type, no I/O. - "bytes.Equal", // compares two byte slices for equality; pure function, no I/O. - "bytes.IndexByte", // finds a byte in a byte slice; pure function, no I/O. - "bytes.NewReader", // wraps a byte slice as an io.Reader; pure in-memory, no I/O. - "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. - "errors.As", // error type assertion; pure function, no I/O. - "errors.Is", // error comparison; pure function, no I/O. - "errors.New", // creates a simple error value; pure function, no I/O. - "fmt.Errorf", // error formatting; pure function, no I/O. - "fmt.Sprintf", // string formatting; pure function, no I/O. - "golang.org/x/sys/unix.SysctlRaw", // macOS: reads kernel socket tables (read-only, no exec, no filesystem). - "io.EOF", // sentinel error value; pure constant. - "io.MultiReader", // combines multiple Readers into one sequential Reader; no I/O side effects. - "io.NopCloser", // wraps a Reader with a no-op Close; no side effects. - "io.ReadCloser", // interface type; no side effects. - "io.ReadSeeker", // interface type combining Reader and Seeker; no side effects. - "io.Reader", // interface type; no side effects. - "io.SeekCurrent", // whence constant for Seek(offset, SeekCurrent); pure constant. - "io.WriteString", // writes a string to a writer; no filesystem access, delegates to Write. - "io.Writer", // interface type for writing; no side effects. - "io/fs.DirEntry", // interface type for directory entries; no side effects. - "io/fs.FileInfo", // interface type for file information; no side effects. - "io/fs.ModeDir", // file mode bit constant for directories; pure constant. - "io/fs.ModeNamedPipe", // file mode bit constant for named pipes; pure constant. - "io/fs.ModeSetgid", // file mode bit constant for setgid; pure constant. - "io/fs.ModeSetuid", // file mode bit constant for setuid; pure constant. - "io/fs.ModeSocket", // file mode bit constant for sockets; pure constant. - "io/fs.ModeSticky", // file mode bit constant for sticky bit; pure constant. - "io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant. - "io/fs.ReadDirFile", // read-only directory handle interface; no write capability. - "math.Ceil", // pure arithmetic; no side effects. - "math.Floor", // pure arithmetic; no side effects. - "math.Inf", // returns positive or negative infinity; pure function, no I/O. - "math.MaxInt32", // integer constant; no side effects. - "math.MaxInt64", // integer constant; no side effects. - "math.MaxUint64", // integer constant; no side effects. - "math.MinInt64", // integer constant; no side effects. - "math.NaN", // returns IEEE 754 NaN value; pure function, no I/O. - "net.FlagBroadcast", // interface flag constant: broadcast capability; pure constant, no network connections. - "net.FlagLoopback", // interface flag constant: is loopback; pure constant, no network connections. - "net.FlagMulticast", // interface flag constant: multicast capability; pure constant, no network connections. - "net.FlagPointToPoint", // interface flag constant: point-to-point link; pure constant, no network connections. - "net.FlagRunning", // interface flag constant: running state (Go 1.20+); pure constant, no network connections. - "net.FlagUp", // interface flag constant: administratively up; pure constant, no network connections. - "net.Flags", // network interface flags type (uint); pure type, no network connections. - "net.IP", // IP address type ([]byte); pure type, no network connections. - "net.IPNet", // IP network struct (IP + Mask); pure type, no network connections. - "net.Interface", // OS network interface descriptor; read-only struct, no network connections. - "net.Interfaces", // read-only OS interface enumeration function; no network connections or writes. - "os.FileInfo", // file metadata interface returned by Stat; no I/O side effects. - "os.IsNotExist", // checks if error is "not exist"; pure function, no I/O. - "os.O_RDONLY", // read-only file flag constant; cannot open files by itself. - "os.PathError", // error type for filesystem path errors; pure type, no I/O. - "path/filepath.ToSlash", // converts OS path separators to forward slashes; pure function, no I/O. - "regexp.Compile", // compiles a regular expression; pure function, no I/O. Uses RE2 engine (linear-time, no backtracking). - "regexp.QuoteMeta", // escapes all special regex characters in a string; pure function, no I/O. - "regexp.Regexp", // compiled regular expression type; no I/O side effects. All matching methods are linear-time (RE2). - "runtime.GOOS", // current OS name constant; pure constant, no I/O. - "slices.Reverse", // reverses a slice in-place; pure function, no I/O. - "slices.SortFunc", // sorts a slice with a comparison function; pure function, no I/O. - "slices.SortStableFunc", // stable sort with a comparison function; pure function, no I/O. - "strconv.Atoi", // string-to-int conversion; pure function, no I/O. - "strconv.ErrRange", // sentinel error value for overflow; pure constant. - "strconv.FormatInt", // int-to-string conversion; pure function, no I/O. - "strconv.FormatUint", // uint-to-string conversion; pure function, no I/O. - "strconv.IntSize", // platform int size constant (32 or 64); pure constant, no I/O. - "strconv.Itoa", // int-to-string conversion; pure function, no I/O. - "strconv.NumError", // error type for numeric conversion failures; pure type. - "strconv.ParseBool", // string-to-bool conversion; pure function, no I/O. - "strconv.ParseFloat", // string-to-float conversion; pure function, no I/O. - "strconv.ParseInt", // string-to-int conversion with base/bit-size; pure function, no I/O. - "strconv.ParseUint", // string-to-unsigned-int conversion; pure function, no I/O. - "strings.Builder", // efficient string concatenation; pure in-memory buffer, no I/O. - "strings.ContainsRune", // checks if a rune is in a string; pure function, no I/O. - "strings.Fields", // splits a string on whitespace into a slice; pure function, no I/O. - "strings.HasPrefix", // pure function for prefix matching; no I/O. - "strings.IndexByte", // finds byte in string; pure function, no I/O. - "strings.Join", // concatenates a slice of strings with a separator; pure function, no I/O. - "strings.ReplaceAll", // replaces all occurrences of a substring; pure function, no I/O. - "strings.Split", // splits a string by separator into a slice; pure function, no I/O. - "strings.ToLower", // converts string to lowercase; pure function, no I/O. - "strings.ToUpper", // converts string to uppercase; pure function, no I/O. - "strings.TrimSpace", // removes leading/trailing whitespace; pure function. - "syscall.ByHandleFileInformation", // Windows file info struct for extracting nlink; read-only type, no I/O. - "syscall.EISDIR", // error number constant for "is a directory"; pure constant, no I/O. - "syscall.ENOENT", // error constant for "no such file or directory"; pure constant, no I/O. - "syscall.Errno", // error type for system call error numbers; pure type, no I/O. - "syscall.GetFileInformationByHandle", // Windows API to query file metadata by handle; read-only, no I/O side effects. - "syscall.Handle", // Windows file handle type; pure type alias, no I/O. - "syscall.Stat_t", // file stat struct for extracting UID/GID/nlink; read-only type, no I/O. - "time.Duration", // duration type; pure integer alias, no I/O. - "time.Hour", // constant representing one hour; no side effects. - "time.Minute", // constant representing one minute; no side effects. - "time.Second", // constant representing one second; no side effects. - "time.Time", // time value type; pure data, no side effects. - "unicode.Cc", // control character category range table; pure data, no I/O. - "unicode.Cf", // format character category range table; pure data, no I/O. - "unicode.Co", // private-use character category range table; pure data, no I/O. - "unicode.Is", // checks if rune belongs to a range table; pure function, no I/O. - "unicode.IsGraphic", // reports whether rune is defined as a graphic character; pure function, no I/O. - "unicode.Me", // enclosing mark category range table; pure data, no I/O. - "unicode.Mn", // nonspacing mark category range table; pure data, no I/O. - "unicode.Range16", // struct type for 16-bit Unicode ranges; pure data. - "unicode.Range32", // struct type for 32-bit Unicode ranges; pure data. - "unicode.RangeTable", // struct type for Unicode range tables; pure data. - "unicode.Zs", // Unicode space separator category range table; pure data, no I/O. - "unicode/utf8.DecodeRune", // decodes first UTF-8 rune from a byte slice; pure function, no I/O. - "unicode/utf8.DecodeRuneInString", // decodes first UTF-8 rune from a string; pure function, no I/O. - "unicode/utf8.RuneError", // replacement character returned for invalid UTF-8; constant, no I/O. - "unicode/utf8.UTFMax", // maximum number of bytes in a UTF-8 encoding; constant, no I/O. - "unicode/utf8.Valid", // checks if a byte slice is valid UTF-8; pure function, no I/O. + "bufio.NewScanner", // line-by-line input reading (e.g. head, cat); no write or exec capability. + "bufio.Scanner", // scanner type for buffered input reading; no write or exec capability. + "bufio.SplitFunc", // type for custom scanner split functions; pure type, no I/O. + "bytes.Equal", // compares two byte slices for equality; pure function, no I/O. + "bytes.IndexByte", // finds a byte in a byte slice; pure function, no I/O. + "bytes.NewReader", // wraps a byte slice as an io.Reader; pure in-memory, no I/O. + "context.Context", // deadline/cancellation plumbing; pure interface, no side effects. + "context.WithTimeout", // creates a child context with a deadline; no filesystem or network I/O itself. + "errors.As", // error type assertion; pure function, no I/O. + "errors.Is", // error comparison; pure function, no I/O. + "errors.New", // creates a simple error value; pure function, no I/O. + "fmt.Errorf", // error formatting; pure function, no I/O. + "fmt.Sprintf", // string formatting; pure function, no I/O. + "github.com/prometheus-community/pro-bing.NewPinger", // creates an ICMP pinger by resolving host; network I/O is the explicit purpose of the ping builtin. + "github.com/prometheus-community/pro-bing.NoopLogger", // no-op logger that discards pro-bing internal messages; no side effects. + "github.com/prometheus-community/pro-bing.Packet", // ICMP packet descriptor struct (received packet data); pure data type, no I/O. + "github.com/prometheus-community/pro-bing.Pinger", // ICMP pinger struct; network I/O is the explicit purpose of the ping builtin. + "github.com/prometheus-community/pro-bing.Statistics", // ping round-trip statistics struct; pure data type, no I/O. + "golang.org/x/sys/unix.SysctlRaw", // macOS: reads kernel socket tables (read-only, no exec, no filesystem). + "io.EOF", // sentinel error value; pure constant. + "io.MultiReader", // combines multiple Readers into one sequential Reader; no I/O side effects. + "io.NopCloser", // wraps a Reader with a no-op Close; no side effects. + "io.ReadCloser", // interface type; no side effects. + "io.ReadSeeker", // interface type combining Reader and Seeker; no side effects. + "io.Reader", // interface type; no side effects. + "io.SeekCurrent", // whence constant for Seek(offset, SeekCurrent); pure constant. + "io.WriteString", // writes a string to a writer; no filesystem access, delegates to Write. + "io.Writer", // interface type for writing; no side effects. + "io/fs.DirEntry", // interface type for directory entries; no side effects. + "io/fs.FileInfo", // interface type for file information; no side effects. + "io/fs.ModeDir", // file mode bit constant for directories; pure constant. + "io/fs.ModeNamedPipe", // file mode bit constant for named pipes; pure constant. + "io/fs.ModeSetgid", // file mode bit constant for setgid; pure constant. + "io/fs.ModeSetuid", // file mode bit constant for setuid; pure constant. + "io/fs.ModeSocket", // file mode bit constant for sockets; pure constant. + "io/fs.ModeSticky", // file mode bit constant for sticky bit; pure constant. + "io/fs.ModeSymlink", // file mode bit constant for symlinks; pure constant. + "io/fs.ReadDirFile", // read-only directory handle interface; no write capability. + "math.Ceil", // pure arithmetic; no side effects. + "math.Floor", // pure arithmetic; no side effects. + "math.Inf", // returns positive or negative infinity; pure function, no I/O. + "math.IsInf", // IEEE 754 infinity check; pure function, no I/O. + "math.IsNaN", // IEEE 754 NaN check; pure function, no I/O. + "math.MaxInt32", // integer constant; no side effects. + "math.MaxInt64", // integer constant; no side effects. + "math.MaxUint64", // integer constant; no side effects. + "math.MinInt64", // integer constant; no side effects. + "math.NaN", // returns IEEE 754 NaN value; pure function, no I/O. + "net.DefaultResolver", // default system DNS resolver; used for context-aware address lookup; network I/O is the explicit purpose of the ping builtin. + "net.FlagBroadcast", // interface flag constant: broadcast capability; pure constant, no network connections. + "net.IPAddr", // resolved IP address struct (IP + Zone); pure data type, no I/O. + "net.FlagLoopback", // interface flag constant: is loopback; pure constant, no network connections. + "net.FlagMulticast", // interface flag constant: multicast capability; pure constant, no network connections. + "net.FlagPointToPoint", // interface flag constant: point-to-point link; pure constant, no network connections. + "net.FlagRunning", // interface flag constant: running state (Go 1.20+); pure constant, no network connections. + "net.FlagUp", // interface flag constant: administratively up; pure constant, no network connections. + "net.Flags", // network interface flags type (uint); pure type, no network connections. + "net.IP", // IP address type ([]byte); pure type, no network connections. + "net.IPNet", // IP network struct (IP + Mask); pure type, no network connections. + "net.ParseIP", // parses an IP address string into a net.IP; pure function, no I/O. + "net.Interface", // OS network interface descriptor; read-only struct, no network connections. + "net.Interfaces", // read-only OS interface enumeration function; no network connections or writes. + "os.FileInfo", // file metadata interface returned by Stat; no I/O side effects. + "os.IsNotExist", // checks if error is "not exist"; pure function, no I/O. + "os.O_RDONLY", // read-only file flag constant; cannot open files by itself. + "os.PathError", // error type for filesystem path errors; pure type, no I/O. + "path/filepath.ToSlash", // converts OS path separators to forward slashes; pure function, no I/O. + "regexp.Compile", // compiles a regular expression; pure function, no I/O. Uses RE2 engine (linear-time, no backtracking). + "regexp.QuoteMeta", // escapes all special regex characters in a string; pure function, no I/O. + "regexp.Regexp", // compiled regular expression type; no I/O side effects. All matching methods are linear-time (RE2). + "runtime.GOOS", // current OS name constant; pure constant, no I/O. + "slices.Reverse", // reverses a slice in-place; pure function, no I/O. + "slices.SortFunc", // sorts a slice with a comparison function; pure function, no I/O. + "slices.SortStableFunc", // stable sort with a comparison function; pure function, no I/O. + "strconv.Atoi", // string-to-int conversion; pure function, no I/O. + "strconv.ErrRange", // sentinel error value for overflow; pure constant. + "strconv.FormatInt", // int-to-string conversion; pure function, no I/O. + "strconv.FormatUint", // uint-to-string conversion; pure function, no I/O. + "strconv.IntSize", // platform int size constant (32 or 64); pure constant, no I/O. + "strconv.Itoa", // int-to-string conversion; pure function, no I/O. + "strconv.NumError", // error type for numeric conversion failures; pure type. + "strconv.ParseBool", // string-to-bool conversion; pure function, no I/O. + "strconv.ParseFloat", // string-to-float conversion; pure function, no I/O. + "strconv.ParseInt", // string-to-int conversion with base/bit-size; pure function, no I/O. + "strconv.ParseUint", // string-to-unsigned-int conversion; pure function, no I/O. + "strings.Builder", // efficient string concatenation; pure in-memory buffer, no I/O. + "strings.Contains", // substring search; pure function, no I/O. + "strings.ContainsRune", // checks if a rune is in a string; pure function, no I/O. + "strings.Fields", // splits a string on whitespace into a slice; pure function, no I/O. + "strings.HasPrefix", // pure function for prefix matching; no I/O. + "strings.IndexByte", // finds byte in string; pure function, no I/O. + "strings.Join", // concatenates a slice of strings with a separator; pure function, no I/O. + "strings.ReplaceAll", // replaces all occurrences of a substring; pure function, no I/O. + "strings.Split", // splits a string by separator into a slice; pure function, no I/O. + "strings.ToLower", // converts string to lowercase; pure function, no I/O. + "strings.ToUpper", // converts string to uppercase; pure function, no I/O. + "strings.TrimSpace", // removes leading/trailing whitespace; pure function. + "syscall.ByHandleFileInformation", // Windows file info struct for extracting nlink; read-only type, no I/O. + "syscall.EACCES", // POSIX errno constant for permission denied; pure constant, no I/O. + "syscall.EISDIR", // error number constant for "is a directory"; pure constant, no I/O. + "syscall.EPERM", // POSIX errno constant for operation not permitted; pure constant, no I/O. + "syscall.EPROTONOSUPPORT", // POSIX errno constant for protocol not supported; pure constant, no I/O. + "syscall.ENOENT", // error constant for "no such file or directory"; pure constant, no I/O. + "syscall.Errno", // error type for system call error numbers; pure type, no I/O. + "syscall.GetFileInformationByHandle", // Windows API to query file metadata by handle; read-only, no I/O side effects. + "syscall.Handle", // Windows file handle type; pure type alias, no I/O. + "syscall.Stat_t", // file stat struct for extracting UID/GID/nlink; read-only type, no I/O. + "time.Duration", // duration type; pure integer alias, no I/O. + "time.Hour", // constant representing one hour; no side effects. + "time.Millisecond", // constant representing one millisecond; no side effects. + "time.Minute", // constant representing one minute; no side effects. + "time.ParseDuration", // parses Go duration strings (e.g. "1s"); pure function, no I/O. + "time.Second", // constant representing one second; no side effects. + "time.Time", // time value type; pure data, no side effects. + "unicode.Cc", // control character category range table; pure data, no I/O. + "unicode.Cf", // format character category range table; pure data, no I/O. + "unicode.Co", // private-use character category range table; pure data, no I/O. + "unicode.Is", // checks if rune belongs to a range table; pure function, no I/O. + "unicode.IsGraphic", // reports whether rune is defined as a graphic character; pure function, no I/O. + "unicode.Me", // enclosing mark category range table; pure data, no I/O. + "unicode.Mn", // nonspacing mark category range table; pure data, no I/O. + "unicode.Range16", // struct type for 16-bit Unicode ranges; pure data. + "unicode.Range32", // struct type for 32-bit Unicode ranges; pure data. + "unicode.RangeTable", // struct type for Unicode range tables; pure data. + "unicode.Zs", // Unicode space separator category range table; pure data, no I/O. + "unicode/utf8.DecodeRune", // decodes first UTF-8 rune from a byte slice; pure function, no I/O. + "unicode/utf8.DecodeRuneInString", // decodes first UTF-8 rune from a string; pure function, no I/O. + "unicode/utf8.RuneError", // replacement character returned for invalid UTF-8; constant, no I/O. + "unicode/utf8.UTFMax", // maximum number of bytes in a UTF-8 encoding; constant, no I/O. + "unicode/utf8.Valid", // checks if a byte slice is valid UTF-8; pure function, no I/O. } diff --git a/builtins/ping/builtin_ping_pentest_test.go b/builtins/ping/builtin_ping_pentest_test.go new file mode 100644 index 00000000..6539e54c --- /dev/null +++ b/builtins/ping/builtin_ping_pentest_test.go @@ -0,0 +1,170 @@ +// 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. + +// Security-focused (pentest) tests for the ping builtin. +// Each test exercises a class of attack and verifies the implementation +// behaves safely (no crash, no hang, exit 0 or 1 only). + +package ping_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// ============================================================================ +// GTFOBins / dangerous flag vectors +// ============================================================================ + +func TestPingPentestFloodRejected(t *testing.T) { + // -f flood is a DoS vector against the target network. + _, stderr, code := cmdRun(t, "ping -f 127.0.0.1") + assert.Equal(t, 1, code, "-f must be rejected as unknown flag") + assert.Contains(t, stderr, "ping:") +} + +func TestPingPentestBroadcastRejected(t *testing.T) { + // -b allows pinging broadcast addresses, which can amplify traffic. + _, stderr, code := cmdRun(t, "ping -b 255.255.255.255") + assert.Equal(t, 1, code, "-b must be rejected as unknown flag") + assert.Contains(t, stderr, "ping:") +} + +func TestPingPentestSizeRejected(t *testing.T) { + // -s would let the caller control payload size; not implemented. + _, stderr, code := cmdRun(t, "ping -s 65507 localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingPentestInterfaceRejected(t *testing.T) { + // -I interface binding not implemented. + _, stderr, code := cmdRun(t, "ping -I eth0 localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingPentestPatternRejected(t *testing.T) { + // -p pattern filling not implemented. + _, stderr, code := cmdRun(t, "ping -p ff localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingPentestRecordRouteRejected(t *testing.T) { + // -R record-route not implemented. + _, stderr, code := cmdRun(t, "ping -R localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +// ============================================================================ +// Integer overflow / boundary inputs +// ============================================================================ + +func TestPingPentestCountNegative(t *testing.T) { + // Negative count clamped to 1; should not hang. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // DNS resolution of "no-such-host-xyzzy.invalid" should fail quickly. + _, _, code := runScriptCtx(ctx, t, "ping -c -99 no-such-host-xyzzy.invalid") + assert.Equal(t, 1, code) + assert.NoError(t, ctx.Err(), "should not exceed 5-second deadline") +} + +func TestPingPentestCountZero(t *testing.T) { + // -c 0 is clamped to 1; should not hang. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, _ = runScriptCtx(ctx, t, "ping -c 0 no-such-host-xyzzy.invalid") + assert.NoError(t, ctx.Err(), "clamped count=0 should not hang") +} + +func TestPingPentestCountOverflow(t *testing.T) { + // Very large count clamped to 20. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, _ = runScriptCtx(ctx, t, "ping -c 2147483647 no-such-host-xyzzy.invalid") + assert.NoError(t, ctx.Err(), "large count should not hang (clamped + DNS fails fast)") +} + +func TestPingPentestIntervalTooShort(t *testing.T) { + // Interval below 200ms floor; should not hang. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, _ = runScriptCtx(ctx, t, "ping -c 1 -i 1ns no-such-host-xyzzy.invalid") + assert.NoError(t, ctx.Err()) +} + +// ============================================================================ +// Path / host injection +// ============================================================================ + +func TestPingPentestShellInjectionInHost(t *testing.T) { + // Verify that the literal string "$(id)" is treated as a hostname (DNS + // lookup fails) rather than triggering command substitution inside the + // builtin. The argument is single-quoted so the shell passes it verbatim + // to ping; only the resolver sees it. + _, _, code := cmdRun(t, `ping -c 1 '$(id)'`) + assert.Equal(t, 1, code) +} + +func TestPingPentestLongHostname(t *testing.T) { + // A very long hostname should result in a DNS error, not a crash. + host := strings.Repeat("a", 10000) + ".invalid" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, code := runScriptCtx(ctx, t, "ping -c 1 -- "+host) + assert.Equal(t, 1, code, "long hostname should fail with exit 1") + assert.NoError(t, ctx.Err(), "should not hang on long hostname") +} + +func TestPingPentestEmptyHostname(t *testing.T) { + // Empty hostname (after shell expansion) should give a clear error. + _, stderr, code := cmdRun(t, `ping -c 1 ""`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +// ============================================================================ +// Context cancellation (timeout safety) +// ============================================================================ + +func TestPingPentestHeaderPrintedBeforeICMP(t *testing.T) { + // The PING header is emitted after DNS resolution but before opening the + // ICMP socket. This test verifies the invariant: stdout contains the + // header even when the ICMP step fails (e.g. EPERM or timeout). + // 192.0.2.1 is an RFC 5737 documentation address that never responds. + stdout, _, code := cmdRun(t, "ping -c 1 -W 100ms 192.0.2.1") + assert.Equal(t, 1, code) + assert.Contains(t, stdout, "PING 192.0.2.1 (192.0.2.1):", "PING header must appear in stdout even when ICMP fails") +} + +func TestPingPentestContextTimeout(t *testing.T) { + // With a very short outer deadline, RunWithContext must exit promptly. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + start := time.Now() + _, _, _ = runScriptCtx(ctx, t, "ping -c 10 -W 10s -i 5s 192.0.2.1") + elapsed := time.Since(start) + assert.Less(t, elapsed, 2*time.Second, "ping must stop when context is cancelled") +} + +func TestPingPentestHardTotalTimeout(t *testing.T) { + // The builtin computes a hard total timeout. With maxed-out count and + // wait, the deadline must still be within the 120s cap. + // We use an unresolvable host so execution terminates on DNS failure. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // -c 20 -W 30s -i 60s = 20*(60+30)+5 = 1805s → capped at 120s. But DNS + // failure returns immediately. + _, _, code := runScriptCtx(ctx, t, "ping -c 20 -W 30s -i 60s no-such-host-xyzzy.invalid") + assert.Equal(t, 1, code) + assert.NoError(t, ctx.Err(), "should not exceed 5-second outer deadline") +} diff --git a/builtins/ping/ping.go b/builtins/ping/ping.go new file mode 100644 index 00000000..122b54fc --- /dev/null +++ b/builtins/ping/ping.go @@ -0,0 +1,592 @@ +// 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 ping implements the ping builtin command. +// +// ping — send ICMP echo requests to a network host +// +// Usage: ping [OPTION]... HOST +// +// Sends ICMP echo requests to HOST and reports round-trip statistics. +// Uses github.com/prometheus-community/pro-bing for ICMP operations. +// +// This command always attempts unprivileged mode first (SetPrivileged(false), +// UDP-based ICMP) and automatically falls back to privileged raw-socket mode +// (SetPrivileged(true), SOCK_RAW) if the OS returns a permission error. +// +// # Platform compatibility +// +// Linux: +// +// Unprivileged ICMP (SOCK_DGRAM) requires the process GID to fall within the +// kernel sysctl net.ipv4.ping_group_range (default 1–0, i.e. disabled on +// many distributions). When that range excludes the GID, the kernel returns +// EPROTONOSUPPORT and the command retries with a raw socket (SOCK_RAW), which +// requires root or CAP_NET_RAW. CAP_NET_RAW is the preferred deployment +// approach in containers; alternatively, widen ping_group_range to allow +// unprivileged operation without any capability. +// +// macOS: +// +// Unprivileged ICMP (SOCK_DGRAM) is permitted by default for all users on +// macOS 10.15+. The privileged fallback (SOCK_RAW) requires root. In normal +// operation the fallback is never needed on macOS. +// +// Windows: +// +// Both SOCK_DGRAM and SOCK_RAW ICMP sockets on Windows require the process to +// run as Administrator. Standard user processes receive WSAEACCES ("access is +// denied") or WSAEPROTONOSUPPORT (10043) when creating the socket. The command +// attempts the unprivileged path first and retries with privileged mode, but +// both attempts will fail for non-elevated processes. Run the shell as +// Administrator (or grant SeNetworkLogonRight) to enable ping on Windows. +// +// Accepted flags: +// +// -c, --count N +// Number of ICMP echo requests to send (default 4, clamped to 1–20). +// +// -W, --wait DURATION +// Time to wait for each reply (default 1s, clamped to 100ms–30s). +// +// -i, --interval DURATION +// Interval between sending packets (default 1s, clamped to 200ms–60s). +// +// -q, --quiet +// Quiet output: suppress per-packet lines; print only statistics. +// +// -4 +// Use IPv4 only. +// +// -6 +// Use IPv6 only. +// +// -h, --help +// Print usage to stdout and exit 0. +// +// Dangerous flags NOT implemented (rejected by pflag as unknown): +// +// -f Flood ping — sends packets as fast as possible (DoS vector). +// -b Allow pinging broadcast addresses (network DoS vector). +// -s SIZE Set packet payload size (not needed; default size is used). +// -I IFACE Bind to specific network interface. +// -p PATTERN Fill packet with pattern. +// -R Record route. +// +// Exit codes: +// +// 0 At least one ICMP echo reply was received. +// 1 No replies received, or the host was unreachable, or bad arguments. +// +// Output format: +// +// PING host (ip): N data bytes +// N bytes from ip: icmp_seq=S ttl=T time=R ms +// ... +// --- host ping statistics --- +// S packets transmitted, R received, X% packet loss +// round-trip min/avg/max/stddev = min/avg/max/stddev ms +package ping + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "strconv" + "strings" + "syscall" + "time" + + probing "github.com/prometheus-community/pro-bing" + + "github.com/DataDog/rshell/builtins" +) + +const ( + defaultCount = 4 + minCount = 1 + maxCount = 20 + defaultInterval = time.Second + minInterval = 200 * time.Millisecond + maxInterval = 60 * time.Second + defaultWait = time.Second + minWait = 100 * time.Millisecond + maxWait = 30 * time.Second + // icmpPayloadSize matches the POSIX standard ping payload (56 data bytes). + icmpPayloadSize = 56 + // pingGracePeriod is added to the total deadline to allow the last reply + // to arrive after the final probe is sent. + pingGracePeriod = 5 * time.Second + // maxTotalTimeout is the hard cap on the total wall-clock run time. + maxTotalTimeout = 120 * time.Second +) + +// Cmd is the ping builtin command descriptor. +var Cmd = builtins.Command{ + Name: "ping", + Description: "send ICMP echo requests to a network host", + MakeFlags: registerFlags, +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + help := fs.BoolP("help", "h", false, "print usage and exit 0") + count := fs.IntP("count", "c", defaultCount, fmt.Sprintf("number of ICMP packets to send (%d–%d)", minCount, maxCount)) + // StringP instead of DurationP so we accept both Go duration literals + // (e.g. "1s", "500ms") and the integer/float seconds that iputils ping + // accepts (e.g. "-W 1", "-i 0.2"). parsePingDuration handles both forms. + waitStr := fs.StringP("wait", "W", defaultWait.String(), fmt.Sprintf("time to wait for each reply (%v–%v)", minWait, maxWait)) + intervalStr := fs.StringP("interval", "i", defaultInterval.String(), fmt.Sprintf("interval between packets (%v–%v)", minInterval, maxInterval)) + quiet := fs.BoolP("quiet", "q", false, "quiet output: suppress per-packet lines") + ipv4 := fs.BoolP("ipv4", "4", false, "use IPv4") + ipv6 := fs.BoolP("ipv6", "6", false, "use IPv6") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *help { + printHelp(callCtx, fs) + return builtins.Result{} + } + + if len(args) == 0 { + callCtx.Errf("ping: missing host operand\nTry 'ping --help' for more information.\n") + return builtins.Result{Code: 1} + } + if len(args) > 1 { + callCtx.Errf("ping: too many arguments\nTry 'ping --help' for more information.\n") + return builtins.Result{Code: 1} + } + + // Parse -W and -i: accept Go duration literals ("1s") and integer/float + // seconds ("1", "0.2") for iputils ping compatibility. + wait, err := parsePingDuration(*waitStr) + if err != nil { + callCtx.Errf("ping: invalid argument %q for \"-W, --wait\" flag: %v\n", *waitStr, err) + return builtins.Result{Code: 1} + } + interval, err := parsePingDuration(*intervalStr) + if err != nil { + callCtx.Errf("ping: invalid argument %q for \"-i, --interval\" flag: %v\n", *intervalStr, err) + return builtins.Result{Code: 1} + } + + // Clamp inputs to safe ranges; warn when the user-supplied value + // is outside the allowed range so the caller is not confused by + // unexpected behaviour (mirrors find's -maxdepth clamping). + c := clampInt(*count, minCount, maxCount) + if *count != c { + callCtx.Errf("ping: warning: -c %d out of range [%d-%d]; clamped to %d\n", *count, minCount, maxCount, c) + } + w := clampDuration(wait, minWait, maxWait) + if wait != w { + callCtx.Errf("ping: warning: -W %v out of range [%v-%v]; clamped to %v\n", wait, minWait, maxWait, w) + } + iv := clampDuration(interval, minInterval, maxInterval) + if interval != iv { + callCtx.Errf("ping: warning: -i %v out of range [%v-%v]; clamped to %v\n", interval, minInterval, maxInterval, iv) + } + + // Hard total deadline: last-packet deadline + grace period. + // pro-bing's Timeout is a global wall-clock deadline. The last packet + // is sent at (count-1)*interval after start; we then wait up to one + // more 'wait' for its reply. So the total is (count-1)*interval + wait. + // At clamped minimums (count=1, interval=200ms, wait=100ms) the floor + // is 0 + 100ms + 5s = 5.1s; callers always get at least that long. + total := time.Duration(c-1)*iv + w + pingGracePeriod + if total > maxTotalTimeout { + total = maxTotalTimeout + callCtx.Errf("ping: warning: total run time capped at %gs; some probes may not complete\n", maxTotalTimeout.Seconds()) + } + runCtx, cancel := context.WithTimeout(ctx, total) + defer cancel() + + return execPing(runCtx, callCtx, args[0], c, w, iv, *quiet, *ipv4, *ipv6) + } +} + +// execPing resolves the host, sets up ICMP probing, and prints results. +func execPing(ctx context.Context, callCtx *builtins.CallContext, host string, count int, wait, interval time.Duration, quiet, ipv4, ipv6 bool) builtins.Result { + pinger, err := buildPinger(ctx, host, count, wait, interval, ipv4, ipv6) + if err != nil { + callCtx.Errf("ping: %v\n", err) + return builtins.Result{Code: 1} + } + + // Print the header before opening the socket, matching real ping behaviour. + // If RunWithContext later fails for a non-permission reason, the header on + // stdout and the error on stderr is intentional — it mirrors what POSIX ping + // does and all ping scenarios are marked skip_assert_against_bash: true. + // pinger.Size is the ICMP echo body size (56 bytes); POSIX "data bytes" refers + // to this same field. Pro-bing stores its timestamp and UUID within those + // 56 bytes, so the displayed count matches the on-wire ICMP payload size. + // Use host (the original argument) for display; pinger.Addr() returns the + // numeric IP because buildPinger passes a resolved IP to probing.NewPinger. + callCtx.Outf("PING %s (%s): %d data bytes\n", host, pinger.IPAddr(), pinger.Size) + + onRecv := makeOnRecv(callCtx, quiet) + pinger.OnRecv = onRecv + + // Attempt unprivileged mode first. + pinger.SetPrivileged(false) + err = pinger.RunWithContext(ctx) + + if err != nil && isPermissionErr(err) { + // EPERM / EACCES / EPROTONOSUPPORT are returned by the internal + // p.listen() call before any packet is sent, so pinger.Statistics() + // is all zeros here and the header printed above is still valid. + // Retry with raw socket privileges. Pass the already-resolved IP so that + // buildPinger skips the DNS round-trip and returns immediately. + // pinger.IPAddr().String() uses net.IPAddr.String(), which preserves the + // zone identifier for link-local IPv6 addresses (e.g. "fe80::1%eth0"), + // ensuring the OS can route the retry correctly. + p2, err2 := buildPinger(ctx, pinger.IPAddr().String(), count, wait, interval, ipv4, ipv6) + if err2 != nil { + callCtx.Errf("ping: %v\n", err2) + return builtins.Result{Code: 1} + } + p2.OnRecv = onRecv + p2.SetPrivileged(true) + err = p2.RunWithContext(ctx) + pinger = p2 + } + + // Print statistics unconditionally — even on non-permission errors and + // context cancellation. This mirrors POSIX ping which always prints + // partial statistics (with 0 received) before exiting, whether due to + // SIGINT, network-unreachable, or timeout. + stats := pinger.Statistics() + printStats(callCtx, host, stats) + + if err != nil && ctx.Err() == nil { + callCtx.Errf("ping: %v\n", err) + return builtins.Result{Code: 1} + } + + if stats.PacketsRecv == 0 { + return builtins.Result{Code: 1} + } + return builtins.Result{} +} + +// buildPinger creates and configures a Pinger with the given parameters. +// DNS resolution is context-aware: cancellation propagates into the DNS query +// itself, avoiding goroutine leaks that would result from wrapping the +// non-context net.ResolveIPAddr. +func buildPinger(ctx context.Context, host string, count int, wait, interval time.Duration, ipv4, ipv6 bool) (*probing.Pinger, error) { + if ipv4 && ipv6 { + return nil, fmt.Errorf("-4 and -6 are mutually exclusive") + } + + // When a family flag is given and the host is a hostname (not a numeric + // IP literal), use a family-specific lookup so that we only wait for the + // requested record type (A or AAAA). This avoids unnecessary latency + // when, for example, -4 is requested but AAAA records are slow or broken. + // LookupIP returns []net.IP without zone info, which is acceptable here + // because DNS-resolved IPv6 link-local addresses (the only case where zone + // matters) are extremely rare in practice. + // For numeric IP literals, use LookupIPAddr instead: parsing is instant + // (no DNS query is issued), preserves our custom "no ip6/ip4 address" error + // messages when the literal doesn't match the requested family, and (unlike + // LookupIP) preserves the zone identifier for scoped IP literals such as + // "fe80::1%eth0". net.ParseIP returns nil for scoped addresses, so we also + // detect them via isScopedIPLiteral. + // When no flag is given, use LookupIPAddr (dual-stack) and select below. + isNumericIP := net.ParseIP(host) != nil || isScopedIPLiteral(host) + var addrs []net.IPAddr + switch { + case ipv4 && !isNumericIP: + ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", host) + if err != nil { + return nil, err + } + for _, ip := range ips { + addrs = append(addrs, net.IPAddr{IP: ip}) + } + case ipv6 && !isNumericIP: + ips, err := net.DefaultResolver.LookupIP(ctx, "ip6", host) + if err != nil { + return nil, err + } + for _, ip := range ips { + addrs = append(addrs, net.IPAddr{IP: ip}) + } + default: + var err error + addrs, err = net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + } + + // Select an address from the resolved set. + // When -4/-6 is given for a hostname: addrs already match the family; + // take the first. For numeric IP literals (resolved via LookupIPAddr) + // we must still filter so that e.g. "ping -4 ::1" correctly errors. + // When neither flag is given: prefer IPv4 (traditional ping default) so + // that AAAA-first DNS results on hosts without working IPv6 do not cause + // spurious failures; fall back to the first IPv6 address if no IPv4 found. + // Known limitation: IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) + // are indistinguishable from native IPv4 at the net.IP byte level — Go's + // net.ParseIP collapses both to the same 16-byte representation, and + // To4() returns non-nil for both. As a result, "ping -6 ::ffff:x.x.x.x" + // behaves identically to "ping -6 x.x.x.x" and returns "no ip6 address". + // This is not fixable without tracking the original string form, and DNS + // AAAA records never return IPv4-mapped addresses in practice. + var resolved *net.IPAddr + if ipv4 || ipv6 { + for i := range addrs { + a := &addrs[i] + isV4 := a.IP.To4() != nil + if (ipv4 && isV4) || (ipv6 && !isV4) { + resolved = a + break + } + } + } else { + // No family flag: prefer IPv4, fall back to first IPv6. + var ipv6Fallback *net.IPAddr + for i := range addrs { + a := &addrs[i] + if a.IP.To4() != nil { + resolved = a + break + } + if ipv6Fallback == nil { + ipv6Fallback = a + } + } + if resolved == nil { + resolved = ipv6Fallback + } + } + if resolved == nil { + family := "ip4" + if ipv6 { + family = "ip6" + } + return nil, fmt.Errorf("no %s address for host %q", family, host) + } + + // Reject broadcast and multicast destinations to prevent unintended DoS. + // The -f and -b flags are already rejected by the flag parser; this + // catches cases where the resolved IP itself is a broadcast or multicast addr. + // + // NOTE: pro-bing v0.8.0 automatically retries sends with SO_BROADCAST set + // on Linux when WriteTo returns EACCES (ping.go sendICMP loop). This means + // that even without -b, a directed-broadcast address (e.g. 10.0.0.255) would + // result in actual ICMP broadcast traffic being sent. We therefore reject + // any IPv4 address whose last octet is 255, which covers: + // - The limited broadcast: 255.255.255.255 + // - All subnet-directed broadcasts on standard /8, /16, /24 networks + // (whose broadcast address always ends in 255). + // Known limitation: directed broadcasts on non-standard subnets (e.g. a /25 + // network whose broadcast is x.x.x.127) are NOT blocked here. Without the + // subnet mask, we cannot enumerate all possible broadcast addresses; blocking + // all addresses with last octet ≤ 127 would be far too aggressive. In those + // environments the OS still enforces SO_BROADCAST for raw sockets except + // that pro-bing's auto-retry circumvents it on Linux. Additionally, if + // SetBroadcastFlag itself fails in the pro-bing sendICMP loop, the loop has + // no exit path until the 120 s context deadline fires — so on such subnets + // the command may stall rather than fail immediately. This is an upstream + // pro-bing v0.8.0 limitation; the 120 s hard cap provides the safety bound. + // Known false-positive: on subnets wider than /24 (e.g. /16 or /23), + // the last octet can be 255 on a valid unicast host (e.g. 10.0.1.255 on + // 10.0.0.0/16, whose broadcast is 10.0.255.255). These rare addresses are + // blocked by this heuristic. Without the subnet mask the shell cannot + // distinguish them from broadcast addresses, and the safety trade-off + // (block a rare unicast > risk unintended broadcast) is appropriate for a + // restricted shell environment. + ip := resolved.IP + if ip.IsUnspecified() { + return nil, fmt.Errorf("unspecified destination not allowed: %s", ip) + } + if ip.IsMulticast() { + return nil, fmt.Errorf("multicast destination not allowed: %s", ip) + } + // Block limited broadcast and subnet-directed broadcast addresses. + if ip4 := ip.To4(); ip4 != nil && ip4[3] == 255 { + return nil, fmt.Errorf("broadcast destination not allowed: %s", ip) + } + + // Pass the numeric IP; pro-bing's internal net.ResolveIPAddr returns + // immediately for a numeric address, so no second DNS round-trip occurs. + // NOTE: NewPinger calls net.ResolveIPAddr without a context, but since + // we always pass a numeric IP here, that call is synchronous and instant — + // no goroutine leak or context-cancellation gap exists in practice. + p, err := probing.NewPinger(resolved.String()) + if err != nil { + return nil, err + } + p.Count = count + p.Size = icmpPayloadSize + // pro-bing Timeout is a global wall-clock deadline, not per-packet. + // The last probe is sent at (count-1)*interval; we then wait up to + // one 'wait' for its reply. + p.Timeout = time.Duration(count-1)*interval + wait + p.Interval = interval + p.SetLogger(probing.NoopLogger{}) + if ipv4 { + p.SetNetwork("ip4") + } else if ipv6 { + p.SetNetwork("ip6") + } + return p, nil +} + +// makeOnRecv returns the OnRecv callback. In quiet mode it returns nil so +// no per-packet output is written. +func makeOnRecv(callCtx *builtins.CallContext, quiet bool) func(*probing.Packet) { + if quiet { + return nil + } + return func(pkt *probing.Packet) { + // pro-bing sequences start at 0; add 1 to match POSIX/bash ping convention + // where the first reply carries icmp_seq=1. + callCtx.Outf("%d bytes from %s: icmp_seq=%d ttl=%d time=%.3f ms\n", + pkt.Nbytes, pkt.IPAddr, pkt.Seq+1, pkt.TTL, durToMS(pkt.Rtt)) + } +} + +// printStats writes the two summary lines that every ping run ends with. +// host is the original argument (hostname or IP) for display in the footer. +func printStats(callCtx *builtins.CallContext, host string, stats *probing.Statistics) { + callCtx.Outf("\n--- %s ping statistics ---\n", host) + callCtx.Outf("%d packets transmitted, %d received, %.1f%% packet loss\n", + stats.PacketsSent, stats.PacketsRecv, stats.PacketLoss) + if stats.PacketsRecv > 0 { + callCtx.Outf("round-trip min/avg/max/stddev = %.3f/%.3f/%.3f/%.3f ms\n", + durToMS(stats.MinRtt), durToMS(stats.AvgRtt), + durToMS(stats.MaxRtt), durToMS(stats.StdDevRtt)) + } +} + +// printHelp writes the usage text to stdout. +func printHelp(callCtx *builtins.CallContext, fs *builtins.FlagSet) { + callCtx.Out("Usage: ping [OPTION]... HOST\n") + callCtx.Out("Send ICMP echo requests to HOST and report statistics.\n\n") + callCtx.Out("Options:\n") + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + callCtx.Out("\nNote: the following flags are not supported for safety and will be rejected:\n") + callCtx.Out(" -f (flood), -b (broadcast), -s (packet size), -I (interface), -p (pattern), -R (record route)\n") +} + +// clampInt returns v clamped to [lo, hi]. +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// clampDuration returns v clamped to [lo, hi]. +func clampDuration(v, lo, hi time.Duration) time.Duration { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// durToMS converts a duration to milliseconds as a float64. +func durToMS(d time.Duration) float64 { + return float64(d.Microseconds()) / 1000.0 +} + +// parsePingDuration parses a duration string for the -W and -i flags. +// It accepts Go duration literals (e.g. "1s", "500ms") and the plain +// integer/float seconds used by iputils ping (e.g. "1", "0.2", "1.5"). +// Negative values are rejected regardless of form; the caller's +// clampDuration handles out-of-range positive values. +func parsePingDuration(s string) (time.Duration, error) { + // Try Go duration literal first — fastest and most precise. + if d, err := time.ParseDuration(s); err == nil { + if d < 0 { + return 0, fmt.Errorf("negative duration %q not allowed", s) + } + return d, nil + } + // Fall back to plain numeric seconds (integer or float) as iputils does. + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: must be a Go duration (e.g. \"1s\", \"500ms\") or seconds (e.g. \"1\", \"0.5\")", s) + } + // Reject non-finite values and values that would overflow time.Duration + // (int64 nanoseconds; max ~9.2 billion seconds = ~292 years). + const maxDurationSec = float64(math.MaxInt64 / int64(time.Second)) + if math.IsNaN(f) || math.IsInf(f, 0) || f > maxDurationSec { + return 0, fmt.Errorf("invalid duration %q: must be a finite positive number", s) + } + if f < 0 { + return 0, fmt.Errorf("negative duration %q not allowed", s) + } + return time.Duration(f * float64(time.Second)), nil +} + +// isPermissionErr reports whether err indicates that the process lacks the +// privilege to open a raw ICMP socket. When true, the caller should retry +// with privileged raw-socket mode. +// +// This function is only called on errors returned by pinger.RunWithContext, +// which come from the ICMP socket layer — not from DNS. DNS errors are caught +// earlier in buildPinger and returned to the caller before RunWithContext is +// ever invoked, so "permission denied" strings here always originate from +// socket creation, never from a DNS resolver response. +// +// We detect three classes of failure: +// 1. EPERM / EACCES — classic Unix permission denials. +// 2. EPROTONOSUPPORT — returned on Linux when the kernel's +// net.ipv4.ping_group_range does not cover the process GID and the +// unprivileged UDP-based ICMP path is unavailable; privileged raw +// sockets are unaffected and should be tried. +// 3. String-based fallback for Windows and platforms that wrap errors. +func isPermissionErr(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) || + errors.Is(err, syscall.EPROTONOSUPPORT) { + return true + } + // String-based fallback for Windows and platforms where pro-bing wraps + // the syscall error in a way that breaks errors.Is unwrapping. On Unix, + // these strings overlap with the errors.Is checks above (e.g. + // syscall.EACCES produces "permission denied"), but they are kept for + // defence-in-depth: pro-bing's internal error path may change across + // versions. The overlap is harmless — a match that is already caught + // above short-circuits before reaching this block. + // + // NOTE: "operation not permitted" / "permission denied" / "protocol not + // supported" cannot originate from DNS here because DNS errors are caught + // in buildPinger before RunWithContext is ever called (see the function + // comment above). Every error reaching this point comes from the ICMP + // socket layer. + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "operation not permitted") || + strings.Contains(msg, "access is denied") || + strings.Contains(msg, "permission denied") || + strings.Contains(msg, "protocol not supported") || + // Windows: WSAEPROTONOSUPPORT (10043) — returned by pro-bing when an + // unprivileged raw socket cannot be created; privileged mode should be tried. + strings.Contains(msg, "the requested protocol has not been configured") +} + +// isScopedIPLiteral reports whether host is a scoped IP address literal with a +// zone suffix, such as "fe80::1%eth0" or "192.168.1.1%eth0". net.ParseIP +// rejects the '%' zone separator, so this function strips the zone and parses +// the base address. In practice only scoped IPv6 link-local addresses carry a +// zone, but the function matches any IP+zone for correctness. +func isScopedIPLiteral(host string) bool { + idx := strings.IndexByte(host, '%') + if idx < 0 { + return false + } + return net.ParseIP(host[:idx]) != nil +} diff --git a/builtins/ping/ping_fuzz_test.go b/builtins/ping/ping_fuzz_test.go new file mode 100644 index 00000000..ddace195 --- /dev/null +++ b/builtins/ping/ping_fuzz_test.go @@ -0,0 +1,156 @@ +// 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 ping_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +// cmdRunCtxFuzz runs a ping command with a caller-supplied context. +// Separate from runScriptCtx to avoid any accidental redeclarations. +func cmdRunCtxFuzz(ctx context.Context, t *testing.T, script string) (string, string, int) { + t.Helper() + return runScriptCtx(ctx, t, script) +} + +// FuzzPingFlags fuzzes flag argument parsing. The command is always directed +// at an unresolvable host so DNS failure terminates the run quickly. The only +// acceptable exit codes are 0 and 1; any other code or panic is a failure. +func FuzzPingFlags(f *testing.F) { + // Source A: implementation boundary values. + f.Add("-c", "1") + f.Add("-c", "4") + f.Add("-c", "20") + f.Add("-c", "0") + f.Add("-c", "-1") + f.Add("-c", "21") + f.Add("-c", "2147483647") + f.Add("-c", "9999999999999999999") + f.Add("-W", "100ms") + f.Add("-W", "1s") + f.Add("-W", "30s") + f.Add("-W", "0s") + f.Add("-W", "31s") + f.Add("-i", "200ms") + f.Add("-i", "1s") + f.Add("-i", "10ms") + f.Add("-i", "0s") + f.Add("-q", "") + f.Add("-4", "") + f.Add("-6", "") + + // Source B: CVE-class and historical boundary inputs. + f.Add("-c", "4294967295") // UINT32_MAX + f.Add("-c", "-2147483648") // INT32_MIN + f.Add("-W", "1000000000s") // absurdly large duration + f.Add("-i", "1ns") // below minimum floor + + // Source C: flag strings derived from test coverage. + f.Add("--count", "5") + f.Add("--wait", "2s") + f.Add("--interval", "500ms") + f.Add("--quiet", "") + f.Add("--help", "") + f.Add("-h", "") + + f.Fuzz(func(t *testing.T, flag, value string) { + // Only allow characters that are safe to pass unquoted in a shell script. + // Using an allowlist is more robust than a denylist: any character not + // explicitly permitted here could cause shell parse errors or command + // injection, so we skip instead of risk a spurious test failure. + for _, r := range flag + value { + safe := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '.' + if !safe { + return + } + } + if len(flag)+len(value) > 256 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + var script string + if value == "" { + script = fmt.Sprintf("ping %s no-such-host-xyzzy.invalid", flag) + } else { + script = fmt.Sprintf("ping %s %s no-such-host-xyzzy.invalid", flag, value) + } + + _, _, code := cmdRunCtxFuzz(ctx, t, script) + if code != 0 && code != 1 { + t.Errorf("unexpected exit code %d for script: %s", code, script) + } + }) +} + +// FuzzPingHostname fuzzes the hostname argument. The command runs with minimal +// packet count (-c 1 -W 500ms) to fail fast on any input. +func FuzzPingHostname(f *testing.F) { + // Source A: boundary and edge-case hostnames. + f.Add("localhost") + f.Add("127.0.0.1") + f.Add("::1") + f.Add("") + f.Add("no-such-host.invalid") + f.Add("a") + f.Add("0.0.0.0") + f.Add("255.255.255.255") + + // Source B: historically problematic inputs (null bytes, long strings, unicode). + f.Add(strings.Repeat("a", 253)) // max FQDN length + f.Add(strings.Repeat("a", 254)) // over max FQDN length + f.Add(strings.Repeat("a", 10000)) // very long + f.Add("192.0.2.1") // TEST-NET (RFC 5737), unroutable + f.Add("198.51.100.1") // TEST-NET-2, unroutable + f.Add("203.0.113.1") // TEST-NET-3, unroutable + f.Add("xn--nxasmq6b.com") // IDN / punycode + f.Add("\x00\x00\x00\x00") // null bytes + f.Add("a..b") // double dot + f.Add("-hostname") // leading dash + + // Source C: from test coverage. + f.Add("no-such-host-xyzzy.invalid") + + f.Fuzz(func(t *testing.T, hostname string) { + // Only allow characters that are safe to pass unquoted as a shell + // argument. An allowlist is more robust than a denylist because the + // shell parser has many special characters and we cannot enumerate + // them all in advance. Valid hostname characters are: letters, digits, + // hyphens, dots (domain labels), and colons (IPv6 addresses). + for _, r := range hostname { + safe := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '.' || r == ':' + if !safe { + return + } + } + if len(hostname) > 10000 { + return + } + if hostname == "" { + return // empty hostname is handled by TestPingPentestEmptyHostname + } + + // 1s is enough for fast DNS + socket attempt; shorter than the + // 3s default to keep CI fuzz runs from stalling on unresolvable + // hostnames when the corpus grows to include slow-DNS entries. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + script := fmt.Sprintf("ping -c 1 -W 500ms -- %s", hostname) + _, _, code := cmdRunCtxFuzz(ctx, t, script) + if code != 0 && code != 1 { + t.Errorf("unexpected exit code %d for hostname: %q", code, hostname) + } + }) +} diff --git a/builtins/ping/ping_internal_test.go b/builtins/ping/ping_internal_test.go new file mode 100644 index 00000000..3ad617e7 --- /dev/null +++ b/builtins/ping/ping_internal_test.go @@ -0,0 +1,192 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// White-box tests for unexported helpers in the ping package. +package ping + +import ( + "errors" + "fmt" + "net" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestIsPermissionErrEPROTONOSUPPORT(t *testing.T) { + assert.True(t, isPermissionErr(syscall.EPROTONOSUPPORT)) +} + +// ============================================================================ +// isPermissionErr +// ============================================================================ + +func TestIsPermissionErrNil(t *testing.T) { + assert.False(t, isPermissionErr(nil)) +} + +func TestIsPermissionErrEPERM(t *testing.T) { + assert.True(t, isPermissionErr(syscall.EPERM)) +} + +func TestIsPermissionErrEACCES(t *testing.T) { + assert.True(t, isPermissionErr(syscall.EACCES)) +} + +func TestIsPermissionErrWrappedEPERM(t *testing.T) { + // net.OpError wrapping an os.SyscallError wrapping EPERM. + inner := &net.OpError{ + Op: "socket", + Err: fmt.Errorf("wrapped: %w", syscall.EPERM), + } + assert.True(t, isPermissionErr(inner)) +} + +func TestIsPermissionErrWrappedEACCES(t *testing.T) { + inner := &net.OpError{ + Op: "socket", + Err: fmt.Errorf("wrapped: %w", syscall.EACCES), + } + assert.True(t, isPermissionErr(inner)) +} + +func TestIsPermissionErrStringFallback(t *testing.T) { + assert.True(t, isPermissionErr(errors.New("operation not permitted"))) + assert.True(t, isPermissionErr(errors.New("OPERATION NOT PERMITTED"))) // case-insensitive + assert.True(t, isPermissionErr(errors.New("access is denied"))) + assert.True(t, isPermissionErr(errors.New("permission denied"))) + // Linux: EPROTONOSUPPORT wrapped as string (e.g. "listen udp4: socket: protocol not supported") + assert.True(t, isPermissionErr(errors.New("protocol not supported"))) + // Windows WSAEPROTONOSUPPORT (10043): returned by pro-bing when unprivileged + // raw socket creation fails; privileged retry should be attempted. + assert.True(t, isPermissionErr(errors.New("The requested protocol has not been configured into the system, or no implementation for it exists."))) +} + +func TestIsPermissionErrUnrelated(t *testing.T) { + assert.False(t, isPermissionErr(errors.New("connection refused"))) + assert.False(t, isPermissionErr(errors.New("no such host"))) + assert.False(t, isPermissionErr(errors.New("i/o timeout"))) +} + +// ============================================================================ +// parsePingDuration +// ============================================================================ + +func TestParsePingDurationGoDuration(t *testing.T) { + d, err := parsePingDuration("1s") + assert.NoError(t, err) + assert.Equal(t, time.Second, d) + + d, err = parsePingDuration("500ms") + assert.NoError(t, err) + assert.Equal(t, 500*time.Millisecond, d) + + d, err = parsePingDuration("1m30s") + assert.NoError(t, err) + assert.Equal(t, 90*time.Second, d) +} + +func TestParsePingDurationIntegerSeconds(t *testing.T) { + d, err := parsePingDuration("1") + assert.NoError(t, err) + assert.Equal(t, time.Second, d) + + d, err = parsePingDuration("30") + assert.NoError(t, err) + assert.Equal(t, 30*time.Second, d) +} + +func TestParsePingDurationFloatSeconds(t *testing.T) { + d, err := parsePingDuration("0.2") + assert.NoError(t, err) + assert.Equal(t, 200*time.Millisecond, d) + + d, err = parsePingDuration("1.5") + assert.NoError(t, err) + assert.Equal(t, 1500*time.Millisecond, d) +} + +func TestParsePingDurationInvalid(t *testing.T) { + _, err := parsePingDuration("abc") + assert.Error(t, err) + + _, err = parsePingDuration("1x") + assert.Error(t, err) +} + +func TestParsePingDurationNegative(t *testing.T) { + _, err := parsePingDuration("-1") + assert.Error(t, err) +} + +func TestParsePingDurationInfNaN(t *testing.T) { + _, err := parsePingDuration("inf") + assert.Error(t, err, "inf should be rejected") + + _, err = parsePingDuration("+Inf") + assert.Error(t, err, "+Inf should be rejected") + + _, err = parsePingDuration("NaN") + assert.Error(t, err, "NaN should be rejected") +} + +func TestParsePingDurationNegativeGoLiteral(t *testing.T) { + // Negative Go duration literals (e.g. "-1s") are explicitly rejected. + // Providing a negative wait/interval is clearly invalid; an error is + // more useful than silently clamping to the minimum. + _, err := parsePingDuration("-1s") + assert.Error(t, err, "negative Go literal should be rejected") + + _, err = parsePingDuration("-250ms") + assert.Error(t, err, "negative Go millisecond literal should be rejected") +} + +func TestParsePingDurationOverflow(t *testing.T) { + // Very large finite float overflows time.Duration (int64 ns) to negative. + // Must be caught before the conversion. + _, err := parsePingDuration("1e20") + assert.Error(t, err, "1e20 seconds should be rejected as overflow") +} + +// ============================================================================ +// clampInt / clampDuration +// ============================================================================ + +func TestClampIntAtMin(t *testing.T) { + assert.Equal(t, 1, clampInt(-5, 1, 20)) + assert.Equal(t, 1, clampInt(0, 1, 20)) + assert.Equal(t, 1, clampInt(1, 1, 20)) +} + +func TestClampIntAtMax(t *testing.T) { + assert.Equal(t, 20, clampInt(21, 1, 20)) + assert.Equal(t, 20, clampInt(1<<30, 1, 20)) +} + +func TestClampIntMiddle(t *testing.T) { + assert.Equal(t, 10, clampInt(10, 1, 20)) +} + +func TestClampDurationAtMin(t *testing.T) { + assert.Equal(t, minInterval, clampDuration(0, minInterval, 60e9)) + assert.Equal(t, minInterval, clampDuration(1, minInterval, 60e9)) + assert.Equal(t, minInterval, clampDuration(minInterval, minInterval, 60e9)) +} + +func TestClampDurationAtMax(t *testing.T) { + assert.Equal(t, maxWait, clampDuration(maxWait+1, 0, maxWait)) +} + +// ============================================================================ +// durToMS +// ============================================================================ + +func TestDurToMS(t *testing.T) { + assert.InDelta(t, 1.0, durToMS(1e6), 1e-9) // 1ms + assert.InDelta(t, 17.045, durToMS(17045e3), 1e-6) // 17.045ms + assert.Equal(t, 0.0, durToMS(0)) +} diff --git a/builtins/ping/ping_test.go b/builtins/ping/ping_test.go new file mode 100644 index 00000000..2c206c44 --- /dev/null +++ b/builtins/ping/ping_test.go @@ -0,0 +1,274 @@ +// 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 ping_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// runScript runs a shell script and returns stdout, stderr, and the exit code. +func runScript(t *testing.T, script string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, "", opts...) +} + +// runScriptCtx runs a shell script with the given context. +func runScriptCtx(ctx context.Context, t *testing.T, script string) (string, string, int) { + t.Helper() + return testutil.RunScriptCtx(ctx, t, script, "") +} + +// cmdRun runs a ping command with no path restrictions (ping uses the network, +// not the AllowedPaths sandbox). +func cmdRun(t *testing.T, script string) (string, string, int) { + t.Helper() + return runScript(t, script) +} + +// skipIfNoNet skips the test unless RSHELL_NET_TEST=1 is set. +func skipIfNoNet(t *testing.T) { + t.Helper() + if os.Getenv("RSHELL_NET_TEST") == "" { + t.Skip("skipping network test (set RSHELL_NET_TEST=1 to enable)") + } +} + +// ============================================================================ +// Help flag +// ============================================================================ + +func TestPingHelp(t *testing.T) { + stdout, stderr, code := cmdRun(t, "ping --help") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: ping") + assert.Contains(t, stdout, "-c") + assert.Contains(t, stdout, "-W") + assert.Contains(t, stdout, "-i") + assert.Contains(t, stdout, "-q") + assert.Contains(t, stdout, "-4") + assert.Contains(t, stdout, "-6") + assert.Empty(t, stderr) +} + +func TestPingHelpShort(t *testing.T) { + stdout, stderr, code := cmdRun(t, "ping -h") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "Usage: ping") + assert.Empty(t, stderr) +} + +func TestPingHelpMentionsFloodBlocked(t *testing.T) { + stdout, _, code := cmdRun(t, "ping --help") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "-f") +} + +// ============================================================================ +// Argument validation +// ============================================================================ + +func TestPingMissingHost(t *testing.T) { + stdout, stderr, code := cmdRun(t, "ping") + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "ping: missing host operand") +} + +func TestPingTooManyArgs(t *testing.T) { + stdout, stderr, code := cmdRun(t, "ping host1 host2") + assert.Equal(t, 1, code) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "ping: too many arguments") +} + +// ============================================================================ +// Unknown / blocked flags +// ============================================================================ + +func TestPingUnknownFlag(t *testing.T) { + _, stderr, code := cmdRun(t, "ping --no-such-flag localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingFloodFlagRejected(t *testing.T) { + // -f (flood) is a DoS vector; must be rejected. + _, stderr, code := cmdRun(t, "ping -f localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingBroadcastFlagRejected(t *testing.T) { + // -b (broadcast) is not implemented. + _, stderr, code := cmdRun(t, "ping -b 255.255.255.255") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingSizeFlagRejected(t *testing.T) { + // -s (packet size) is not implemented. + _, stderr, code := cmdRun(t, "ping -s 1000 localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +func TestPingInterfaceFlagRejected(t *testing.T) { + // -I (interface) is not implemented. + _, stderr, code := cmdRun(t, "ping -I eth0 localhost") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ping:") +} + +// ============================================================================ +// Flag acceptance — these tests verify flags are parsed without crashing. +// They use a non-existent host so the actual ping fails fast with exit 1, +// but the important thing is the flags are accepted (not rejected as unknown). +// ============================================================================ + +func TestPingCountFlagAccepted(t *testing.T) { + // -c is a registered flag; should not give "unknown flag" error. + _, stderr, _ := cmdRun(t, "ping -c 2 no-such-host.invalid") + assert.NotContains(t, stderr, "unknown flag") +} + +func TestPingWaitFlagAccepted(t *testing.T) { + _, stderr, _ := cmdRun(t, "ping -W 500ms no-such-host.invalid") + assert.NotContains(t, stderr, "unknown flag") +} + +func TestPingIntervalFlagAccepted(t *testing.T) { + _, stderr, _ := cmdRun(t, "ping -i 500ms no-such-host.invalid") + assert.NotContains(t, stderr, "unknown flag") +} + +func TestPingQuietFlagAccepted(t *testing.T) { + _, stderr, _ := cmdRun(t, "ping -q no-such-host.invalid") + assert.NotContains(t, stderr, "unknown flag") +} + +func TestPingIPv4FlagAccepted(t *testing.T) { + _, stderr, _ := cmdRun(t, "ping -4 no-such-host.invalid") + assert.NotContains(t, stderr, "unknown flag") +} + +func TestPingIPv6FlagAccepted(t *testing.T) { + _, stderr, _ := cmdRun(t, "ping -6 no-such-host.invalid") + assert.NotContains(t, stderr, "unknown flag") +} + +// ============================================================================ +// Context cancellation — RunWithContext must respect context deadline. +// ============================================================================ + +func TestPingContextCancel(t *testing.T) { + // Very short deadline: the ping should return promptly, not hang. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + start := time.Now() + // Use a real-looking but unreachable address to ensure pro-bing actually + // tries to send packets (so context cancellation is exercised). + _, _, _ = runScriptCtx(ctx, t, "ping -c 10 -W 5s -i 1s 192.0.2.1") + elapsed := time.Since(start) + assert.Less(t, elapsed, 3*time.Second, "ping should have been cancelled within deadline") +} + +// ============================================================================ +// Network tests (require RSHELL_NET_TEST=1) +// ============================================================================ + +func TestPingLocalhost(t *testing.T) { + skipIfNoNet(t) + stdout, stderr, code := cmdRun(t, "ping -c 2 127.0.0.1") + assert.Equal(t, 0, code, "localhost ping should succeed; stderr: %s", stderr) + assert.Contains(t, stdout, "PING") + assert.Contains(t, stdout, "ping statistics") + assert.Contains(t, stdout, "packets transmitted") +} + +func TestPingLocalhostIPv4Flag(t *testing.T) { + skipIfNoNet(t) + stdout, _, code := cmdRun(t, "ping -4 -c 2 127.0.0.1") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "PING") +} + +func TestPingIPv6Localhost(t *testing.T) { + skipIfNoNet(t) + // Use -6 with the IPv6 loopback address. This covers the ipv6 branch in buildPinger. + // Skip if IPv6 is not available on this system. + stdout, stderr, code := cmdRun(t, "ping -6 -c 2 ::1") + if code != 0 { + // IPv6 may not be available in all CI environments; skip rather than fail. + t.Skipf("IPv6 ping to ::1 failed (code=%d, stderr=%s); IPv6 may not be available", code, stderr) + } + assert.Contains(t, stdout, "PING") +} + +func TestPingQuietOutput(t *testing.T) { + skipIfNoNet(t) + stdout, _, code := cmdRun(t, "ping -q -c 2 127.0.0.1") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "ping statistics") + // In quiet mode, no per-packet lines starting with "bytes from" + assert.NotContains(t, stdout, "bytes from") +} + +func TestPingCountClamp(t *testing.T) { + skipIfNoNet(t) + // -c 0 is clamped to 1; should send exactly 1 packet, not hang. + start := time.Now() + stdout, _, code := cmdRun(t, "ping -c 0 127.0.0.1") + elapsed := time.Since(start) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "1 packets transmitted") + assert.Less(t, elapsed, 10*time.Second, "clamped -c 0 should complete quickly") +} + +func TestPingCountLargeClamp(t *testing.T) { + skipIfNoNet(t) + // -c 9999 is clamped to 20; command should finish, not hang for hours. + start := time.Now() + stdout, _, code := cmdRun(t, "ping -c 9999 -i 50ms -W 500ms 127.0.0.1") + elapsed := time.Since(start) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "20 packets transmitted") + // 20 packets at 50ms interval with 500ms wait each = ~20 * 550ms = 11s max. + assert.Less(t, elapsed, 30*time.Second) +} + +func TestPingIntervalClamp(t *testing.T) { + skipIfNoNet(t) + // -i 10ms is below the 200ms minimum floor; should still work (clamped). + _, stderr, code := cmdRun(t, "ping -c 2 -i 10ms 127.0.0.1") + assert.Equal(t, 0, code) + assert.NotContains(t, stderr, "unknown flag") +} + +func TestPingStatisticsOutputFormat(t *testing.T) { + skipIfNoNet(t) + stdout, _, code := cmdRun(t, "ping -c 2 127.0.0.1") + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "packets transmitted") + assert.Contains(t, stdout, "received") + assert.Contains(t, stdout, "packet loss") + // Statistics include RTT when packets were received. + assert.Contains(t, stdout, "round-trip min/avg/max/stddev") +} + +func TestPingUnreachableHostExitCode(t *testing.T) { + skipIfNoNet(t) + // An unresolvable host should exit 1. + _, _, code := cmdRun(t, "ping -c 1 -W 1s no-such-host-xyzzy-invalid.example") + assert.Equal(t, 1, code) +} diff --git a/go.mod b/go.mod index 9bd8dbc7..42a88c05 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/DataDog/rshell go 1.25.6 require ( + github.com/prometheus-community/pro-bing v0.8.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -13,6 +14,9 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index f4b4d24a..930144f8 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -13,6 +15,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= +github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -24,6 +28,10 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/interp/register_builtins.go b/interp/register_builtins.go index f1927007..2b35f8b8 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -22,6 +22,7 @@ import ( "github.com/DataDog/rshell/builtins/help" "github.com/DataDog/rshell/builtins/ip" "github.com/DataDog/rshell/builtins/ls" + "github.com/DataDog/rshell/builtins/ping" printfcmd "github.com/DataDog/rshell/builtins/printf" "github.com/DataDog/rshell/builtins/sed" sortcmd "github.com/DataDog/rshell/builtins/sort" @@ -53,6 +54,7 @@ func registerBuiltins() { help.Cmd, ip.Cmd, ls.Cmd, + ping.Cmd, sortcmd.Cmd, printfcmd.Cmd, sed.Cmd, diff --git a/tests/scenarios/cmd/ping/errors/broadcast_address_rejected.yaml b/tests/scenarios/cmd/ping/errors/broadcast_address_rejected.yaml new file mode 100644 index 00000000..ff570434 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/broadcast_address_rejected.yaml @@ -0,0 +1,10 @@ +# Pinging the limited broadcast address 255.255.255.255 must be rejected. +description: "ping 255.255.255.255 (broadcast address) exits 1" +input: + script: |+ + ping -c 1 255.255.255.255 +expect: + stdout: "" + stderr: "ping: broadcast destination not allowed: 255.255.255.255\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/broadcast_false_positive.yaml b/tests/scenarios/cmd/ping/errors/broadcast_false_positive.yaml new file mode 100644 index 00000000..8199fd3d --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/broadcast_false_positive.yaml @@ -0,0 +1,13 @@ +# 10.0.1.255 ends in .255 so it is rejected by the broadcast heuristic. +# On a 10.0.0.0/16 network this would be a valid unicast address (broadcast +# is 10.0.255.255), but without subnet-mask knowledge the heuristic blocks it. +# This documents the known false-positive described in buildPinger. +description: "ping blocks .255 address even if unicast on wider subnet (known false-positive)" +input: + script: |+ + ping -c 1 10.0.1.255 +expect: + stdout: "" + stderr: "ping: broadcast destination not allowed: 10.0.1.255\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/broadcast_flag_rejected.yaml b/tests/scenarios/cmd/ping/errors/broadcast_flag_rejected.yaml new file mode 100644 index 00000000..2c784ecc --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/broadcast_flag_rejected.yaml @@ -0,0 +1,10 @@ +# -b (broadcast) is a network DoS vector and must be rejected as an unknown flag. +description: "ping -b (broadcast) is rejected with exit 1" +input: + script: |+ + ping -b 255.255.255.255 +expect: + stdout: "" + stderr: "ping: unknown shorthand flag: 'b' in -b\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/directed_broadcast_rejected.yaml b/tests/scenarios/cmd/ping/errors/directed_broadcast_rejected.yaml new file mode 100644 index 00000000..fd58ca81 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/directed_broadcast_rejected.yaml @@ -0,0 +1,12 @@ +# Subnet-directed broadcast addresses (last octet 255) must be rejected. +# pro-bing v0.8.0 auto-retries sends with SO_BROADCAST on EACCES, so we block +# any IPv4 address ending in .255 before passing it to pro-bing. +description: "ping 10.0.0.255 (directed broadcast) exits 1" +input: + script: |+ + ping -c 1 10.0.0.255 +expect: + stdout: "" + stderr: "ping: broadcast destination not allowed: 10.0.0.255\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/flood_flag_rejected.yaml b/tests/scenarios/cmd/ping/errors/flood_flag_rejected.yaml new file mode 100644 index 00000000..bc32b491 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/flood_flag_rejected.yaml @@ -0,0 +1,10 @@ +# -f (flood) is a DoS vector and must be rejected as an unknown flag. +description: "ping -f (flood) is rejected with exit 1" +input: + script: |+ + ping -f localhost +expect: + stdout: "" + stderr: "ping: unknown shorthand flag: 'f' in -f\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/interface_flag_rejected.yaml b/tests/scenarios/cmd/ping/errors/interface_flag_rejected.yaml new file mode 100644 index 00000000..95b3dc21 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/interface_flag_rejected.yaml @@ -0,0 +1,10 @@ +# -I (bind to interface) is rejected as an unknown flag. +description: "ping -I (interface) is rejected with exit 1" +input: + script: |+ + ping -I eth0 localhost +expect: + stdout: "" + stderr: "ping: unknown shorthand flag: 'I' in -I\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/ipv4_ipv6_conflict.yaml b/tests/scenarios/cmd/ping/errors/ipv4_ipv6_conflict.yaml new file mode 100644 index 00000000..cecb7d33 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/ipv4_ipv6_conflict.yaml @@ -0,0 +1,10 @@ +# -4 and -6 are mutually exclusive; supplying both must exit 1. +description: "ping with -4 and -6 together exits 1" +input: + script: |+ + ping -4 -6 localhost +expect: + stdout: "" + stderr: "ping: -4 and -6 are mutually exclusive\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/ipv4_only_host_with_ipv6_flag.yaml b/tests/scenarios/cmd/ping/errors/ipv4_only_host_with_ipv6_flag.yaml new file mode 100644 index 00000000..5ffe366d --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/ipv4_only_host_with_ipv6_flag.yaml @@ -0,0 +1,10 @@ +# Requesting IPv6 for an IPv4-literal address must report no ip6 address found. +description: "ping -6 with IPv4 literal address exits 1" +input: + script: |+ + ping -6 -c 1 -W 100ms 192.0.2.1 +expect: + stdout: "" + stderr: "ping: no ip6 address for host \"192.0.2.1\"\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/ipv6_only_host_with_ipv4_flag.yaml b/tests/scenarios/cmd/ping/errors/ipv6_only_host_with_ipv4_flag.yaml new file mode 100644 index 00000000..65d3c5e0 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/ipv6_only_host_with_ipv4_flag.yaml @@ -0,0 +1,10 @@ +# Requesting IPv4 for an IPv6-literal address must report no ip4 address found. +description: "ping -4 with IPv6 literal address exits 1" +input: + script: |+ + ping -4 -c 1 -W 100ms ::1 +expect: + stdout: "" + stderr: "ping: no ip4 address for host \"::1\"\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/ipv6_unspecified_rejected.yaml b/tests/scenarios/cmd/ping/errors/ipv6_unspecified_rejected.yaml new file mode 100644 index 00000000..53cde718 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/ipv6_unspecified_rejected.yaml @@ -0,0 +1,11 @@ +# Pinging :: (IPv6 unspecified address) must be rejected. +# ip.IsUnspecified() covers both 0.0.0.0 (IPv4) and :: (IPv6). +description: "ping :: (IPv6 unspecified address) exits 1" +input: + script: |+ + ping -c 1 -W 100ms :: +expect: + stdout: "" + stderr: "ping: unspecified destination not allowed: ::\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/missing_host.yaml b/tests/scenarios/cmd/ping/errors/missing_host.yaml new file mode 100644 index 00000000..9f4ca163 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/missing_host.yaml @@ -0,0 +1,10 @@ +# ping with no arguments must exit 1 with a clear error. +description: "ping without a host argument exits 1 with usage hint" +input: + script: |+ + ping +expect: + stdout: "" + stderr: "ping: missing host operand\nTry 'ping --help' for more information.\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/multicast_address_rejected.yaml b/tests/scenarios/cmd/ping/errors/multicast_address_rejected.yaml new file mode 100644 index 00000000..22583cbf --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/multicast_address_rejected.yaml @@ -0,0 +1,10 @@ +# Pinging a multicast address must be rejected. +description: "ping 224.0.0.1 (multicast address) exits 1" +input: + script: |+ + ping -c 1 224.0.0.1 +expect: + stdout: "" + stderr: "ping: multicast destination not allowed: 224.0.0.1\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/pattern_flag_rejected.yaml b/tests/scenarios/cmd/ping/errors/pattern_flag_rejected.yaml new file mode 100644 index 00000000..4aa683ce --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/pattern_flag_rejected.yaml @@ -0,0 +1,10 @@ +# -p (fill pattern) is rejected as an unknown flag. +description: "ping -p (pattern) is rejected with exit 1" +input: + script: |+ + ping -p ff localhost +expect: + stdout: "" + stderr: "ping: unknown shorthand flag: 'p' in -p\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/record_route_flag_rejected.yaml b/tests/scenarios/cmd/ping/errors/record_route_flag_rejected.yaml new file mode 100644 index 00000000..d8b723eb --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/record_route_flag_rejected.yaml @@ -0,0 +1,10 @@ +# -R (record route) is rejected as an unknown flag. +description: "ping -R (record route) is rejected with exit 1" +input: + script: |+ + ping -R localhost +expect: + stdout: "" + stderr: "ping: unknown shorthand flag: 'R' in -R\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/size_flag_rejected.yaml b/tests/scenarios/cmd/ping/errors/size_flag_rejected.yaml new file mode 100644 index 00000000..886a6f64 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/size_flag_rejected.yaml @@ -0,0 +1,10 @@ +# -s (packet size) is not implemented and must be rejected. +description: "ping -s (packet size) is rejected as unknown flag" +input: + script: |+ + ping -s 1000 localhost +expect: + stdout: "" + stderr: "ping: unknown shorthand flag: 's' in -s\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/too_many_args.yaml b/tests/scenarios/cmd/ping/errors/too_many_args.yaml new file mode 100644 index 00000000..1a7c9a56 --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/too_many_args.yaml @@ -0,0 +1,10 @@ +# ping with more than one positional argument must exit 1. +description: "ping with two host arguments exits 1" +input: + script: |+ + ping host1 host2 +expect: + stdout: "" + stderr: "ping: too many arguments\nTry 'ping --help' for more information.\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/unknown_flag.yaml b/tests/scenarios/cmd/ping/errors/unknown_flag.yaml new file mode 100644 index 00000000..8f42603a --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/unknown_flag.yaml @@ -0,0 +1,10 @@ +# Unknown flags must be rejected with exit 1. +description: "ping with unknown flag exits 1" +input: + script: |+ + ping --no-such-flag localhost +expect: + stdout: "" + stderr: "ping: unknown flag: --no-such-flag\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/errors/unspecified_address_rejected.yaml b/tests/scenarios/cmd/ping/errors/unspecified_address_rejected.yaml new file mode 100644 index 00000000..7580be1d --- /dev/null +++ b/tests/scenarios/cmd/ping/errors/unspecified_address_rejected.yaml @@ -0,0 +1,10 @@ +# Pinging 0.0.0.0 (unspecified address) must be rejected. +description: "ping 0.0.0.0 (unspecified address) exits 1" +input: + script: |+ + ping -c 1 -W 100ms 0.0.0.0 +expect: + stdout: "" + stderr: "ping: unspecified destination not allowed: 0.0.0.0\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/count_clamped_warns.yaml b/tests/scenarios/cmd/ping/flags/count_clamped_warns.yaml new file mode 100644 index 00000000..514eccdc --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/count_clamped_warns.yaml @@ -0,0 +1,14 @@ +# -c values above maxCount (20) are clamped with a stderr warning. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -c with value above limit emits clamping warning" +input: + script: |+ + ping -c 100 no-such-host-xyzzy.invalid +expect: + stdout: "" + # stderr_contains is used instead of stderr because the DNS error message + # that follows the warning is platform-variable (resolver error text differs + # across Linux, macOS, and Windows). + stderr_contains: ["ping: warning: -c 100 out of range"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/count_clamped_warns_below_min.yaml b/tests/scenarios/cmd/ping/flags/count_clamped_warns_below_min.yaml new file mode 100644 index 00000000..590e1a83 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/count_clamped_warns_below_min.yaml @@ -0,0 +1,14 @@ +# -c values below minimum (1) are clamped with a stderr warning. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -c 0 emits clamping warning and exits 1" +input: + script: |+ + ping -c 0 no-such-host-xyzzy.invalid +expect: + stdout: "" + # stderr_contains is used instead of stderr because the DNS error message + # that follows the warning is platform-variable (resolver error text differs + # across Linux, macOS, and Windows). + stderr_contains: ["ping: warning: -c 0 out of range"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/float_wait_accepted.yaml b/tests/scenarios/cmd/ping/flags/float_wait_accepted.yaml new file mode 100644 index 00000000..77f5da7d --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/float_wait_accepted.yaml @@ -0,0 +1,11 @@ +# parsePingDuration supports float seconds (e.g. "0.5") for iputils compatibility. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -W with float seconds value is accepted" +input: + script: |+ + ping -c 1 -W 0.5 no-such-host-xyzzy.invalid +expect: + stdout: "" + stderr_contains: ["ping:"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/help_flag.yaml b/tests/scenarios/cmd/ping/flags/help_flag.yaml new file mode 100644 index 00000000..d5dc793f --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/help_flag.yaml @@ -0,0 +1,10 @@ +# -h / --help must print usage to stdout and exit 0. +description: "ping --help prints usage to stdout and exits 0" +input: + script: |+ + ping --help +expect: + stdout_contains: ["Usage: ping"] + stderr: "" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/help_short.yaml b/tests/scenarios/cmd/ping/flags/help_short.yaml new file mode 100644 index 00000000..22159384 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/help_short.yaml @@ -0,0 +1,10 @@ +# -h is the short form of --help. +description: "ping -h prints usage to stdout and exits 0" +input: + script: |+ + ping -h +expect: + stdout_contains: ["Usage: ping"] + stderr: "" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/integer_wait_accepted.yaml b/tests/scenarios/cmd/ping/flags/integer_wait_accepted.yaml new file mode 100644 index 00000000..dd9e9cf9 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/integer_wait_accepted.yaml @@ -0,0 +1,12 @@ +# -W and -i accept plain integer/float seconds for iputils ping compatibility. +# "ping -W 1" means wait 1 second, same as "ping -W 1s". +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -W with bare integer seconds is accepted" +input: + script: |+ + ping -c 1 -W 1 no-such-host-xyzzy.invalid +expect: + stdout: "" + stderr_contains: ["no-such-host-xyzzy.invalid"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/interval_clamped_warns.yaml b/tests/scenarios/cmd/ping/flags/interval_clamped_warns.yaml new file mode 100644 index 00000000..9824874d --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/interval_clamped_warns.yaml @@ -0,0 +1,14 @@ +# -i values below minInterval (200ms) are clamped with a stderr warning. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -i with value below minimum emits clamping warning" +input: + script: |+ + ping -c 1 -i 10ms no-such-host-xyzzy.invalid +expect: + stdout: "" + # stderr_contains is used instead of stderr because the DNS error message + # that follows the warning is platform-variable (resolver error text differs + # across Linux, macOS, and Windows). + stderr_contains: ["ping: warning: -i 10ms out of range"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/interval_clamped_warns_above_max.yaml b/tests/scenarios/cmd/ping/flags/interval_clamped_warns_above_max.yaml new file mode 100644 index 00000000..59d3200c --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/interval_clamped_warns_above_max.yaml @@ -0,0 +1,11 @@ +# -i values above maxInterval (60s) are clamped with a stderr warning. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -i with value above maximum emits clamping warning" +input: + script: |+ + ping -c 1 -i 120s no-such-host-xyzzy.invalid +expect: + stdout: "" + stderr_contains: ["ping: warning: -i 2m0s out of range"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/invalid_interval_rejected.yaml b/tests/scenarios/cmd/ping/flags/invalid_interval_rejected.yaml new file mode 100644 index 00000000..242ac352 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/invalid_interval_rejected.yaml @@ -0,0 +1,9 @@ +# -i with a non-numeric, non-duration string is rejected with exit 1. +description: "ping -i with invalid value is rejected" +input: + script: |+ + ping -i xyz localhost +expect: + stderr: "ping: invalid argument \"xyz\" for \"-i, --interval\" flag: invalid duration \"xyz\": must be a Go duration (e.g. \"1s\", \"500ms\") or seconds (e.g. \"1\", \"0.5\")\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/invalid_wait_rejected.yaml b/tests/scenarios/cmd/ping/flags/invalid_wait_rejected.yaml new file mode 100644 index 00000000..cfee40e3 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/invalid_wait_rejected.yaml @@ -0,0 +1,9 @@ +# -W with a non-numeric, non-duration string is rejected with exit 1. +description: "ping -W with invalid value is rejected" +input: + script: |+ + ping -W abc localhost +expect: + stderr: "ping: invalid argument \"abc\" for \"-W, --wait\" flag: invalid duration \"abc\": must be a Go duration (e.g. \"1s\", \"500ms\") or seconds (e.g. \"1\", \"0.5\")\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/negative_interval_rejected.yaml b/tests/scenarios/cmd/ping/flags/negative_interval_rejected.yaml new file mode 100644 index 00000000..75e8f093 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/negative_interval_rejected.yaml @@ -0,0 +1,9 @@ +# -i with a negative Go duration literal is rejected with exit 1. +description: "ping -i with negative Go duration literal exits 1" +input: + script: |+ + ping -i -1s localhost +expect: + stderr_contains: ["ping: invalid argument"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/quiet_flag.yaml b/tests/scenarios/cmd/ping/flags/quiet_flag.yaml new file mode 100644 index 00000000..ba275648 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/quiet_flag.yaml @@ -0,0 +1,12 @@ +# -q / --quiet flag is accepted (not an unknown flag). +# Using a broadcast address so the command exits immediately with no network I/O. +# Note: the quiet output-suppression behaviour is verified in TestPingQuietOutput +# (requires RSHELL_NET_TEST=1) because it needs a live ICMP round trip. +description: "ping -q flag is accepted (broadcast rejection before ICMP)" +input: + script: |+ + ping -q -c 1 255.255.255.255 +expect: + stderr: "ping: broadcast destination not allowed: 255.255.255.255\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/total_time_cap_warns.yaml b/tests/scenarios/cmd/ping/flags/total_time_cap_warns.yaml new file mode 100644 index 00000000..50477d8b --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/total_time_cap_warns.yaml @@ -0,0 +1,11 @@ +# -c 20 -i 60s -W 30s gives total = 19*60 + 30 + 5 = 1175s > 120s cap. +# DNS fails immediately so the test completes in milliseconds. +description: "ping warns when total run time would exceed 120s cap" +input: + script: |+ + ping -c 20 -i 60s -W 30s no-such-host-xyzzy.invalid +expect: + stdout: "" + stderr_contains: ["ping: warning: total run time capped at 120s"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/wait_clamped_warns.yaml b/tests/scenarios/cmd/ping/flags/wait_clamped_warns.yaml new file mode 100644 index 00000000..c58eeeb7 --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/wait_clamped_warns.yaml @@ -0,0 +1,14 @@ +# -W values above maxWait (30s) are clamped with a stderr warning. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -W with value above limit emits clamping warning" +input: + script: |+ + ping -c 1 -W 60s no-such-host-xyzzy.invalid +expect: + stdout: "" + # stderr_contains is used instead of stderr because the DNS error message + # that follows the warning is platform-variable (resolver error text differs + # across Linux, macOS, and Windows). + stderr_contains: ["ping: warning: -W 1m0s out of range"] + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/ping/flags/wait_clamped_warns_below_min.yaml b/tests/scenarios/cmd/ping/flags/wait_clamped_warns_below_min.yaml new file mode 100644 index 00000000..4f7243cf --- /dev/null +++ b/tests/scenarios/cmd/ping/flags/wait_clamped_warns_below_min.yaml @@ -0,0 +1,11 @@ +# -W values below minWait (100ms) are clamped with a stderr warning. +# DNS fails immediately on the unresolvable host so the test exits fast. +description: "ping -W with value below minimum emits clamping warning" +input: + script: |+ + ping -c 1 -W 50ms no-such-host-xyzzy.invalid +expect: + stdout: "" + stderr_contains: ["ping: warning: -W 50ms out of range"] + exit_code: 1 +skip_assert_against_bash: true