diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efd6b2b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: ci + +on: + pull_request: + paths-ignore: + - '**.md' + push: + branches: + - main + paths-ignore: + - '**.md' + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: "go.mod" + cache: false + + - name: Ensure go modules are tidy + run: | + go mod tidy + if [[ -n $(git status -s) ]] ; then + echo + echo -e "\e[31mRunning 'go mod tidy' changes the current setting" + echo -e "\e[31mEnsure to include updated go.mod and go.sum in this PR." + echo -e "\e[31mThis is usually done by running 'go mod tidy'\e[0m" + git status -s + git diff --color + exit 1 + fi + + - name: Run linters + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: latest + + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: "go.mod" + cache: false + + - run: go mod download + + - run: make build + + - name: Build gendetections + run: go build -v -trimpath ./scripts/gendetections + + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: "go.mod" + cache: false + + - run: go mod download + + - run: make test diff --git a/.gitignore b/.gitignore index 79b5594..c5d1d19 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ **/.DS_Store +detections.json diff --git a/Dockerfile b/Dockerfile index 79f5545..b95539b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,15 +10,22 @@ ENV GOARCH=$TARGETARCH ENV GOOS=linux RUN go build -v -trimpath -ldflags="-w -s -X 'main.version=$(git describe --abbrev=0 --tags | sed s/v//)'" -o /go/bin/shell2http . +RUN go build -v -trimpath -o /go/bin/gendetections ./scripts/gendetections + +# Generate detections.json from the base image's scripts +FROM quay.io/crowdstrike/detection-container AS detector +COPY --from=builder /go/bin/gendetections /gendetections +RUN /gendetections /home/eval/bin > /detections.json # final image FROM quay.io/crowdstrike/detection-container LABEL org.opencontainers.image.source="https://github.com/CrowdStrike/vulnapp" +COPY --from=detector /detections.json /detections.json +COPY --from=builder /go/bin/shell2http /shell2http COPY entrypoint.sh / COPY images /images -COPY --from=builder /go/bin/shell2http /shell2http EXPOSE 8080 diff --git a/Makefile b/Makefile index f84c0e0..9cf0a0d 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ run: build: go build . +generate-detections: + go run ./scripts/gendetections $(SCRIPTS_DIR) + update-from-github: go install github.com/msoap/$(APP_NAME)@latest diff --git a/config.go b/config.go index 4530240..fe81377 100644 --- a/config.go +++ b/config.go @@ -69,6 +69,7 @@ type Config struct { includeStderr bool // also returns output written to stderr (default is stdout only) intServerErr bool // return 500 error if shell status code != 0 formCheckRe *regexp.Regexp // regexp for check form fields + commandsFile string // JSON file with path/command/description entries } // getConfig - parse arguments @@ -108,6 +109,7 @@ func getConfig() (*Config, error) { flag.StringVar(&cfg.key, "key", "", "SSL private key `/path/...`") flag.Var(&cfg.auth, "basic-auth", "setup HTTP Basic Authentication (\"user_name:password\"), can be used several times") flag.IntVar(&cfg.timeout, "timeout", 0, "set `timeout` for execute shell command (in seconds)") + flag.StringVar(&cfg.commandsFile, "commands-file", "", "JSON `file` with path/command/description entries") formCheck := flag.String("form-check", "", "regexp for check form fields (pass only vars that match the regexp)") diff --git a/entrypoint.sh b/entrypoint.sh index fcac440..b53da64 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,16 +7,4 @@ cd /home/eval # Mark as CS testcontainer sh -c echo CS_testcontainer starting -exec /shell2http -show-errors -include-stderr \ - /ps "ps aux" \ - /rootkit ./bin/Defense_Evasion_via_Rootkit.sh \ - /masquerading ./bin/Defense_Evasion_via_Masquerading.sh \ - /data_exfiltration ./bin/Exfiltration_via_Exfiltration_Over_Alternative_Protocol.sh \ - /reverse_shell_trojan './bin/Reverse_Shell_Trojan.sh' \ - /deploy_malware './bin/evil/Linux_Malware_High' \ - /reverse_shell ./bin/Command_Control_via_Remote_Access.sh \ - /reverse_shell-obfuscated ./bin/Command_Control_via_Remote_Access-obfuscated.sh \ - /credentials_dumping ./bin/Credential_Access_via_Credential_Dumping.sh \ - /credentials_dumping_collection ./bin/Collection_via_Automated_Collection.sh \ - /suspicious_commands ./bin/Execution_via_Command-Line_Interface.sh \ - /container_drift ./bin/ContainerDrift_Via_File_Creation_and_Execution.sh +exec /shell2http -show-errors -include-stderr -commands-file /detections.json diff --git a/go.mod b/go.mod index d496ae0..10f750b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/msoap/shell2http -go 1.18 +go 1.26 require ( github.com/mattn/go-shellwords v1.0.12 diff --git a/scripts/gendetections/gendetections.go b/scripts/gendetections/gendetections.go new file mode 100644 index 0000000..8f10ccf --- /dev/null +++ b/scripts/gendetections/gendetections.go @@ -0,0 +1,109 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type commandEntry struct { + Path string `json:"path"` + Command string `json:"command"` + Description string `json:"description,omitempty"` +} + +func parseScriptHeaders(path string) (shortname, description string, err error) { + f, err := os.Open(path) + if err != nil { + return "", "", fmt.Errorf("open %s: %w", path, err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "#") { + break + } + if v, ok := strings.CutPrefix(line, "# Shortname:"); ok { + shortname = strings.TrimSpace(v) + } else if v, ok := strings.CutPrefix(line, "# Description:"); ok { + description = strings.TrimSpace(v) + } + } + if err := scanner.Err(); err != nil { + return "", "", fmt.Errorf("read %s: %w", path, err) + } + return shortname, description, nil +} + +func scanDirectory(dir string) ([]commandEntry, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read directory %s: %w", dir, err) + } + + seen := map[string]string{} + var cmds []commandEntry + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sh") { + continue + } + + path := filepath.Join(dir, e.Name()) + shortname, description, err := parseScriptHeaders(path) + if err != nil { + return nil, err + } + if shortname == "" || description == "" { + continue + } + + urlPath := "/" + shortname + if prev, ok := seen[urlPath]; ok { + return nil, fmt.Errorf("duplicate path %q: %s and %s", urlPath, prev, e.Name()) + } + seen[urlPath] = e.Name() + + cmds = append(cmds, commandEntry{ + Path: urlPath, + Command: "./" + filepath.Join("bin", e.Name()), + Description: description, + }) + } + + if len(cmds) == 0 { + return nil, fmt.Errorf("no valid detection scripts found in %s", dir) + } + + return cmds, nil +} + +func run() error { + if len(os.Args) != 2 { + return fmt.Errorf("usage: gendetections ") + } + + cmds, err := scanDirectory(os.Args[1]) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(cmds); err != nil { + return fmt.Errorf("encode JSON: %w", err) + } + + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "gendetections: %s\n", err) + os.Exit(1) + } +} diff --git a/scripts/gendetections/gendetections_test.go b/scripts/gendetections/gendetections_test.go new file mode 100644 index 0000000..a016efc --- /dev/null +++ b/scripts/gendetections/gendetections_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeScript(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil { + t.Fatal(err) + } +} + +func Test_parseScriptHeaders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantShortname string + wantDescription string + }{ + { + name: "both headers", + content: "#!/bin/sh\n# Shortname: rootkit\n# Description: Does rootkit things.\necho hello\n", + wantShortname: "rootkit", + wantDescription: "Does rootkit things.", + }, + { + name: "missing shortname", + content: "#!/bin/sh\n# Description: Some desc.\necho hello\n", + wantShortname: "", + wantDescription: "Some desc.", + }, + { + name: "missing description", + content: "#!/bin/sh\n# Shortname: foo\necho hello\n", + wantShortname: "foo", + wantDescription: "", + }, + { + name: "no headers", + content: "#!/bin/sh\necho hello\n", + wantShortname: "", + wantDescription: "", + }, + { + name: "stops at non-comment line", + content: "#!/bin/sh\n# Shortname: before\necho break\n# Description: after\n", + wantShortname: "before", + wantDescription: "", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "test.sh") + if err := os.WriteFile(path, []byte(tt.content), 0o644); err != nil { + t.Fatal(err) + } + + shortname, description, err := parseScriptHeaders(path) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if shortname != tt.wantShortname { + t.Errorf("shortname = %q, want %q", shortname, tt.wantShortname) + } + if description != tt.wantDescription { + t.Errorf("description = %q, want %q", description, tt.wantDescription) + } + }) + } +} + +func Test_scanDirectory(t *testing.T) { + t.Parallel() + + t.Run("valid scripts", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "alpha.sh", + "#!/bin/sh\n# Shortname: alpha\n# Description: Alpha detection.\necho a\n") + writeScript(t, dir, "beta.sh", + "#!/bin/sh\n# Shortname: beta\n# Description: Beta detection.\necho b\n") + + cmds, err := scanDirectory(dir) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(cmds) != 2 { + t.Fatalf("got %d entries, want 2", len(cmds)) + } + if cmds[0].Path != "/alpha" || cmds[0].Command != "./bin/alpha.sh" { + t.Errorf("entry 0 = %+v", cmds[0]) + } + if cmds[1].Path != "/beta" || cmds[1].Command != "./bin/beta.sh" { + t.Errorf("entry 1 = %+v", cmds[1]) + } + }) + + t.Run("skips missing shortname", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "good.sh", + "#!/bin/sh\n# Shortname: good\n# Description: Good.\necho g\n") + writeScript(t, dir, "bad.sh", + "#!/bin/sh\n# Description: No shortname.\necho b\n") + + cmds, err := scanDirectory(dir) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(cmds) != 1 { + t.Fatalf("got %d entries, want 1", len(cmds)) + } + if cmds[0].Path != "/good" { + t.Errorf("expected /good, got %s", cmds[0].Path) + } + }) + + t.Run("skips missing description", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "good.sh", + "#!/bin/sh\n# Shortname: good\n# Description: Good.\necho g\n") + writeScript(t, dir, "bad.sh", + "#!/bin/sh\n# Shortname: nodesc\necho b\n") + + cmds, err := scanDirectory(dir) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(cmds) != 1 { + t.Fatalf("got %d entries, want 1", len(cmds)) + } + }) + + t.Run("skips non-sh files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "good.sh", + "#!/bin/sh\n# Shortname: good\n# Description: Good.\necho g\n") + writeScript(t, dir, "readme.txt", + "# Shortname: fake\n# Description: Not a script.\n") + + cmds, err := scanDirectory(dir) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(cmds) != 1 { + t.Fatalf("got %d entries, want 1", len(cmds)) + } + }) + + t.Run("skips directories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "good.sh", + "#!/bin/sh\n# Shortname: good\n# Description: Good.\necho g\n") + if err := os.Mkdir(filepath.Join(dir, "subdir.sh"), 0o755); err != nil { + t.Fatal(err) + } + + cmds, err := scanDirectory(dir) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(cmds) != 1 { + t.Fatalf("got %d entries, want 1", len(cmds)) + } + }) + + t.Run("error on duplicate shortnames", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "alpha.sh", + "#!/bin/sh\n# Shortname: same\n# Description: First.\necho a\n") + writeScript(t, dir, "beta.sh", + "#!/bin/sh\n# Shortname: same\n# Description: Second.\necho b\n") + + _, err := scanDirectory(dir) + if err == nil { + t.Fatal("expected error for duplicate shortnames") + } + if got := err.Error(); !strings.Contains(got, "duplicate path") { + t.Errorf("error = %q, want it to mention duplicate path", got) + } + }) + + t.Run("error on empty results", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeScript(t, dir, "nope.sh", "#!/bin/sh\necho no headers\n") + + _, err := scanDirectory(dir) + if err == nil { + t.Fatal("expected error for no valid entries") + } + }) + + t.Run("error on nonexistent directory", func(t *testing.T) { + t.Parallel() + _, err := scanDirectory("/nonexistent/path") + if err == nil { + t.Fatal("expected error for nonexistent directory") + } + }) +} diff --git a/shell2http.go b/shell2http.go index d8d5ce4..2904bbd 100644 --- a/shell2http.go +++ b/shell2http.go @@ -2,11 +2,11 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "html" "io" - "io/ioutil" "log" "mime/multipart" "net" @@ -311,18 +311,49 @@ func execShellCommand(appConfig Config, shell string, params []string, req *http return shellOut, exitCode, err } -var cmdDescriptions = map[string]string{ - "./bin/Defense_Evasion_via_Rootkit.sh": "This script will change the group owner of /etc/ld.so.preload to 0, indicative of a Jynx Rootkit.", - "./bin/Defense_Evasion_via_Masquerading.sh": "Creates a copy of /usr/bin/whoami to whoami.rtf and executes it, causing a contradicting file extension.", - "./bin/Exfiltration_via_Exfiltration_Over_Alternative_Protocol.sh": "Attempts to exfiltrate data using DNS dig requests that contain system data in the hostname.", - "./bin/Command_Control_via_Remote_Access.sh": "Attempts to connect to a remote IP address and will exit at fork. Falcon Prevent will kill the attempt.", - "./bin/Command_Control_via_Remote_Access-obfuscated.sh": "Attempts to connect to a remote IP address and will exit at fork. Falcon Prevent will kill the attempt. (obfuscated version)", - "./bin/Credential_Access_via_Credential_Dumping.sh": "Runs mimipenguin and tries to dump passwords from inside the container environment.", - "./bin/Collection_via_Automated_Collection.sh": "Attempts to dump credentials from /etc/passwd to /tmp/passwords.", - "./bin/Execution_via_Command-Line_Interface.sh": "Emulate malicious activity related to suspicious CLI commands. Runs the command sh -c whoami '[S];pwd;echo [E]'.", - "./bin/Malware_Linux_Trojan_Local.sh": "Attempts to execute malware pre-loaded into the container. A Falcon Prevent policy will kill the process, if Falcon Prevent is enabled.", - "./bin/Malware_Linux_Trojan_Remote.sh": "Downloads malware from a remote target and attempts to execute it. A Falcon Prevent policy will kill the process, if Falcon Prevent is enabled.", - "./bin/ContainerDrift_Via_File_Creation_and_Execution.sh": "Container Drift via file creation script. Creating a file and then executing it.", +var cmdDescriptions = map[string]string{} + +// commandEntry represents a single path/command pair from a commands file. +type commandEntry struct { + Path string `json:"path"` + Command string `json:"command"` + Description string `json:"description,omitempty"` +} + +// loadCommandsFile reads a JSON file of command entries and returns commands +// for handler registration. Entries with descriptions populate cmdDescriptions. +func loadCommandsFile(path string) ([]command, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read commands file %s: %w", path, err) + } + + var entries []commandEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("parse commands file %s: %w", path, err) + } + + seen := map[string]bool{} + cmds := make([]command, 0, len(entries)) + for _, e := range entries { + if e.Path == "" { + return nil, fmt.Errorf("commands file %s: entry missing path", path) + } + if e.Command == "" { + return nil, fmt.Errorf("commands file %s: entry for %q missing command", path, e.Path) + } + if seen[e.Path] { + return nil, fmt.Errorf("commands file %s: duplicate path %q", path, e.Path) + } + seen[e.Path] = true + + if e.Description != "" { + cmdDescriptions[e.Command] = e.Description + } + cmds = append(cmds, command{path: e.Path, cmd: e.Command}) + } + + return cmds, nil } func describeCmd(cmd string) string { @@ -425,7 +456,7 @@ func responseWrite(rw io.Writer, text string) { func setCGIEnv(cmd *exec.Cmd, req *http.Request, appConfig Config) { // set HTTP_* variables for headerName, headerValue := range req.Header { - envName := strings.ToUpper(strings.Replace(headerName, "-", "_", -1)) + envName := strings.ToUpper(strings.ReplaceAll(headerName, "-", "_")) if envName == "PROXY" { continue } @@ -545,11 +576,11 @@ func getForm(cmd *exec.Cmd, req *http.Request, checkFormRe *regexp.Regexp) (func uplFile, err = value[0].Open() return err }, func() error { - tempDir, err = ioutil.TempDir("", "shell2http_") + tempDir, err = os.MkdirTemp("", "shell2http_") return err }, func() error { prefix := safeFileNameRe.ReplaceAllString(reqFileName, "") - outFile, err = ioutil.TempFile(tempDir, prefix+"_") + outFile, err = os.CreateTemp(tempDir, prefix+"_") return err }, func() error { _, err = io.Copy(outFile, uplFile) @@ -650,9 +681,24 @@ func main() { log.Fatal(err) } - cmdHandlers, err := parsePathAndCommands(flag.Args()) - if err != nil { - log.Fatalf("failed to parse arguments: %s", err) + var cmdHandlers []command + if appConfig.commandsFile != "" { + cmdHandlers, err = loadCommandsFile(appConfig.commandsFile) + if err != nil { + log.Fatalf("failed to load commands file: %s", err) + } + } + + if args := flag.Args(); len(args) > 0 { + argHandlers, err := parsePathAndCommands(args) + if err != nil { + log.Fatalf("failed to parse arguments: %s", err) + } + cmdHandlers = append(cmdHandlers, argHandlers...) + } + + if len(cmdHandlers) == 0 { + log.Fatal("requires at least one path/command pair via -commands-file or arguments") } var cacheTTL raphanus.DB diff --git a/shell2http_test.go b/shell2http_test.go index 149c213..4693813 100644 --- a/shell2http_test.go +++ b/shell2http_test.go @@ -3,7 +3,6 @@ package main import ( "fmt" "io" - "io/ioutil" "net" "net/http" "os" @@ -136,7 +135,7 @@ func httpRequest(method, url, postData string) ([]byte, error) { return nil, err } - body, err := ioutil.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { return nil, err } @@ -315,6 +314,95 @@ func Test_errChainAll(t *testing.T) { } } +func Test_loadCommandsFile(t *testing.T) { + tests := []struct { + name string + content string + wantCmds []command + wantDescCount int + wantErr bool + }{ + { + name: "valid with descriptions", + content: `[{"path":"/a","command":"cmd_a","description":"desc a"},{"path":"/b","command":"cmd_b"}]`, + wantCmds: []command{ + {path: "/a", cmd: "cmd_a"}, + {path: "/b", cmd: "cmd_b"}, + }, + wantDescCount: 1, + wantErr: false, + }, + { + name: "missing path", + content: `[{"command":"cmd_a"}]`, + wantErr: true, + }, + { + name: "missing command", + content: `[{"path":"/a"}]`, + wantErr: true, + }, + { + name: "duplicate paths", + content: `[{"path":"/a","command":"cmd_a"},{"path":"/a","command":"cmd_b"}]`, + wantErr: true, + }, + { + name: "invalid json", + content: `not json`, + wantErr: true, + }, + { + name: "empty array", + content: `[]`, + wantCmds: []command{}, + wantDescCount: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // reset global state + cmdDescriptions = map[string]string{} + + f, err := os.CreateTemp("", "commands-*.json") + if err != nil { + t.Fatalf("create temp file: %s", err) + } + t.Cleanup(func() { _ = os.Remove(f.Name()) }) + + if _, err := f.WriteString(tt.content); err != nil { + t.Fatalf("write temp file: %s", err) + } + _ = f.Close() + + got, err := loadCommandsFile(f.Name()) + if (err != nil) != tt.wantErr { + t.Errorf("loadCommandsFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if !reflect.DeepEqual(got, tt.wantCmds) { + t.Errorf("loadCommandsFile() = %v, want %v", got, tt.wantCmds) + } + if len(cmdDescriptions) != tt.wantDescCount { + t.Errorf("cmdDescriptions has %d entries, want %d", len(cmdDescriptions), tt.wantDescCount) + } + }) + } +} + +func Test_loadCommandsFile_notFound(t *testing.T) { + cmdDescriptions = map[string]string{} + _, err := loadCommandsFile("/nonexistent/path.json") + if err == nil { + t.Error("loadCommandsFile() expected error for missing file") + } +} + func Test_parsePathAndCommands(t *testing.T) { tests := []struct { name string