36b3af2e3d
PR Check / Code Quality: Format (push) Failing after 1s
PR Check / Code Quality: Lint (darwin) (push) Failing after 0s
PR Check / Code Quality: Lint (freebsd) (push) Failing after 1s
PR Check / Code Quality: Lint (windows) (push) Failing after 1s
PR Check / Code Quality: Lint (linux) (push) Failing after 1s
PR Check / Security: Vulnerability Scan (push) Failing after 0s
Update Documentation / update-docs (push) Failing after 2s
PR Check / Code Quality: Vendor (push) Failing after 1s
PR Check / Code Quality: Coverage (push) Failing after 0s
PR Check / Tests: Unit (macos-latest) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04-arm) (push) Has been cancelled
PR Check / Tests: Unit (windows-latest) (push) Has been cancelled
305 lines
8.6 KiB
Go
305 lines
8.6 KiB
Go
package source
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pranshuparmar/witr/pkg/model"
|
|
)
|
|
|
|
func TestDetectShellWindowsCaseInsensitive(t *testing.T) {
|
|
// Windows reports the shell with mixed casing (e.g. "Explorer.EXE"). Shell
|
|
// detection must be case-insensitive, otherwise desktop-launched apps fall
|
|
// through to SourceUnknown and pick up a spurious no-supervisor warning.
|
|
for _, shell := range []string{"Explorer.EXE", "cmd.EXE", "PowerShell.exe"} {
|
|
ancestry := []model.Process{
|
|
{PID: 100, Command: shell},
|
|
{PID: 200, PPID: 100, Command: "Claude.exe"},
|
|
}
|
|
if got := Detect(ancestry).Type; got != model.SourceShell {
|
|
t.Errorf("Detect with %q ancestor = %v; want SourceShell", shell, got)
|
|
}
|
|
if slices.Contains(Warnings(ancestry, 0), "No known supervisor or service manager detected") {
|
|
t.Errorf("%q ancestor should not raise the no-supervisor warning", shell)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectWindowsSystemKernel(t *testing.T) {
|
|
// A process rooted at the Windows System process (PID 4) is a kernel/system
|
|
// process, not an unsupervised one. It must resolve to an init source and
|
|
// must not raise the no-supervisor warning (the Memory Compression false
|
|
// positive).
|
|
ancestry := []model.Process{
|
|
{PID: 4, Command: "System"},
|
|
{PID: 4108, PPID: 4, Command: "Memory Compression"},
|
|
}
|
|
if got := Detect(ancestry).Type; got != model.SourceInit {
|
|
t.Errorf("Detect with System (pid 4) root = %v; want SourceInit", got)
|
|
}
|
|
if slices.Contains(Warnings(ancestry, 0), "No known supervisor or service manager detected") {
|
|
t.Errorf("System-rooted process should not raise the no-supervisor warning")
|
|
}
|
|
}
|
|
|
|
func TestWarningsDetectsLDPreload(t *testing.T) {
|
|
p := []model.Process{
|
|
{PID: 999999, Command: "pm2", Cmdline: "pm2"},
|
|
{
|
|
PID: 123,
|
|
Command: "bash",
|
|
StartedAt: time.Now(),
|
|
User: "bob",
|
|
WorkingDir: "/home/bob",
|
|
Env: []string{"LD_PRELOAD=/tmp/libhack.so"},
|
|
},
|
|
}
|
|
|
|
warnings := Warnings(p, 0)
|
|
if !slices.Contains(warnings, "Process sets LD_PRELOAD (potential library injection)") {
|
|
t.Fatalf("expected LD_PRELOAD warning, got: %v", warnings)
|
|
}
|
|
}
|
|
|
|
func TestWarningsDetectsDYLDVars(t *testing.T) {
|
|
p := []model.Process{
|
|
{PID: 999999, Command: "pm2", Cmdline: "pm2"},
|
|
{
|
|
PID: 123,
|
|
Command: "zsh",
|
|
StartedAt: time.Now(),
|
|
User: "bob",
|
|
WorkingDir: "/home/bob",
|
|
Env: []string{
|
|
"DYLD_LIBRARY_PATH=/tmp",
|
|
"DYLD_INSERT_LIBRARIES=/tmp/inject.dylib",
|
|
},
|
|
},
|
|
}
|
|
|
|
warnings := Warnings(p, 0)
|
|
want := "Process sets DYLD_* variables (potential library injection): DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH"
|
|
if !slices.Contains(warnings, want) {
|
|
t.Fatalf("expected DYLD warning %q, got: %v", want, warnings)
|
|
}
|
|
}
|
|
|
|
func TestWarningsIgnoresEmptyPreloadVars(t *testing.T) {
|
|
p := []model.Process{
|
|
{PID: 999999, Command: "pm2", Cmdline: "pm2"},
|
|
{
|
|
PID: 123,
|
|
Command: "zsh",
|
|
StartedAt: time.Now(),
|
|
User: "bob",
|
|
WorkingDir: "/home/bob",
|
|
Env: []string{
|
|
"LD_PRELOAD=",
|
|
"DYLD_INSERT_LIBRARIES=",
|
|
},
|
|
},
|
|
}
|
|
|
|
warnings := Warnings(p, 0)
|
|
if slices.Contains(warnings, "Process sets LD_PRELOAD (potential library injection)") {
|
|
t.Fatalf("did not expect LD_PRELOAD warning, got: %v", warnings)
|
|
}
|
|
if slices.Contains(warnings, "Process sets DYLD_* variables (potential library injection): DYLD_INSERT_LIBRARIES") {
|
|
t.Fatalf("did not expect DYLD warning, got: %v", warnings)
|
|
}
|
|
}
|
|
|
|
// checks if the order of env vars warnings are deterministic
|
|
func FuzzEnvSuspiciousWarningsDeterministic(f *testing.F) {
|
|
f.Add("LD_PRELOAD=/tmp/lib.so")
|
|
f.Add("DYLD_LIBRARY_PATH=/tmp\nDYLD_INSERT_LIBRARIES=/tmp/inject.dylib")
|
|
f.Add("DYLD_LIBRARY_PATH=\nLD_PRELOAD=")
|
|
f.Add("")
|
|
|
|
f.Fuzz(func(t *testing.T, input string) {
|
|
parts := strings.Split(input, "\n")
|
|
if len(parts) > 50 {
|
|
parts = parts[:50]
|
|
}
|
|
for i := range parts {
|
|
if len(parts[i]) > 200 {
|
|
parts[i] = parts[i][:200]
|
|
}
|
|
}
|
|
|
|
w1 := envSuspiciousWarnings(parts)
|
|
w2 := envSuspiciousWarnings(parts)
|
|
if !slices.Equal(w1, w2) {
|
|
t.Fatalf("expected deterministic output, got %v vs %v", w1, w2)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEnvSuspiciousWarnings(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "LD_PRELOAD",
|
|
env: []string{"LD_PRELOAD=/tmp/libhack.so"},
|
|
want: []string{"Process sets LD_PRELOAD (potential library injection)"},
|
|
},
|
|
{
|
|
name: "DYLD keys sorted and deduped",
|
|
env: []string{
|
|
"DYLD_LIBRARY_PATH=/tmp",
|
|
"DYLD_INSERT_LIBRARIES=/tmp/inject.dylib",
|
|
"DYLD_LIBRARY_PATH=/tmp", // dup
|
|
},
|
|
want: []string{
|
|
"Process sets DYLD_* variables (potential library injection): DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH",
|
|
},
|
|
},
|
|
{
|
|
name: "ignores empty values (current behavior)",
|
|
env: []string{"LD_PRELOAD=", "DYLD_INSERT_LIBRARIES="},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "value with '=' still counts",
|
|
env: []string{"LD_PRELOAD=a=b"},
|
|
want: []string{"Process sets LD_PRELOAD (potential library injection)"},
|
|
},
|
|
{
|
|
name: "no '=' ignored",
|
|
env: []string{"LD_PRELOAD"},
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := envSuspiciousWarnings(tt.env)
|
|
if !slices.Equal(got, tt.want) {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// attempts to cause a panic when checking for env vars
|
|
func FuzzWarningsNoPanic(f *testing.F) {
|
|
f.Add("LD_PRELOAD=/tmp/lib.so")
|
|
f.Add("DYLD_INSERT_LIBRARIES=/tmp/inject.dylib")
|
|
f.Add("NOT_AN_ENV")
|
|
|
|
f.Fuzz(func(t *testing.T, entry string) {
|
|
if len(entry) > 2000 {
|
|
entry = entry[:2000]
|
|
}
|
|
p := []model.Process{
|
|
{
|
|
PID: 123,
|
|
Command: "test",
|
|
Cmdline: "test",
|
|
StartedAt: time.Now(),
|
|
User: "bob",
|
|
WorkingDir: "/home/bob",
|
|
Env: []string{entry},
|
|
},
|
|
}
|
|
|
|
_ = Warnings(p, 0)
|
|
})
|
|
}
|
|
|
|
func TestWarningsDetectsDeletedExecutable(t *testing.T) {
|
|
p := []model.Process{
|
|
{
|
|
PID: 123,
|
|
Command: "nginx",
|
|
StartedAt: time.Now(),
|
|
ExeDeleted: true,
|
|
},
|
|
}
|
|
|
|
warnings := Warnings(p, 0)
|
|
want := "Process is running from a deleted binary (potential library injection or pending update)"
|
|
if !slices.Contains(warnings, want) {
|
|
t.Fatalf("expected deleted binary warning, got: %v", warnings)
|
|
}
|
|
}
|
|
|
|
func TestEnrichSocketInfo(t *testing.T) {
|
|
tests := []struct {
|
|
state string
|
|
wantExplanation string
|
|
wantWorkaround string // empty means no workaround should be set
|
|
}{
|
|
{
|
|
state: "TIME_WAIT",
|
|
wantExplanation: "The local OS is holding the port in a protocol-wait state to ensure all packets are received.",
|
|
wantWorkaround: "Wait ~60s for the OS to release it, or enable SO_REUSEADDR in your code.",
|
|
},
|
|
{
|
|
state: "CLOSE_WAIT",
|
|
wantExplanation: "The remote end has closed the connection, but the local application hasn't responded.",
|
|
wantWorkaround: "This usually indicates a resource leak in the application. Restart the process.",
|
|
},
|
|
{
|
|
state: "LISTEN",
|
|
wantExplanation: "The process is actively waiting for incoming connections.",
|
|
wantWorkaround: "", // no workaround for a healthy listener
|
|
},
|
|
{
|
|
state: "ESTABLISHED",
|
|
wantExplanation: "The connection is active and data can be transferred.",
|
|
wantWorkaround: "",
|
|
},
|
|
{
|
|
state: "FIN_WAIT_1",
|
|
wantExplanation: "The connection is in the process of being closed.",
|
|
wantWorkaround: "",
|
|
},
|
|
{
|
|
state: "FIN_WAIT_2",
|
|
wantExplanation: "The connection is in the process of being closed.",
|
|
wantWorkaround: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
si := &model.SocketInfo{State: tt.state}
|
|
EnrichSocketInfo(si)
|
|
if si.Explanation != tt.wantExplanation {
|
|
t.Errorf("state %s: got explanation %q, want %q", tt.state, si.Explanation, tt.wantExplanation)
|
|
}
|
|
if si.Workaround != tt.wantWorkaround {
|
|
t.Errorf("state %s: got workaround %q, want %q", tt.state, si.Workaround, tt.wantWorkaround)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEnrichSocketInfoUnknownState ensures unknown states pass through
|
|
// without panic and without populating either string. Forward-compatible
|
|
// states added by newer kernels (e.g. NEW_SYN_RECV) must not corrupt the
|
|
// record.
|
|
func TestEnrichSocketInfoUnknownState(t *testing.T) {
|
|
si := &model.SocketInfo{State: "MYSTERY"}
|
|
EnrichSocketInfo(si)
|
|
if si.Explanation != "" || si.Workaround != "" {
|
|
t.Errorf("unknown state should leave fields blank, got explanation=%q workaround=%q",
|
|
si.Explanation, si.Workaround)
|
|
}
|
|
}
|
|
|
|
// TestEnrichSocketInfoNilSafe ensures EnrichSocketInfo is safe to call on a
|
|
// nil pointer (some callers pass *SocketInfo conditionally).
|
|
func TestEnrichSocketInfoNilSafe(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("EnrichSocketInfo(nil) panicked: %v", r)
|
|
}
|
|
}()
|
|
EnrichSocketInfo(nil)
|
|
}
|