diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7232e67 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test ./... diff --git a/.gitignore b/.gitignore index f27bcaf..a725465 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -go.sum vendor/ \ No newline at end of file diff --git a/README.md b/README.md index 45e4a0f..e21e59c 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,15 @@ cfuzz -w [wordlist] Or if you prefer in one line: ```Shell # example for subdomain enum -cfuzz -w [wordlist] -t 5 ping -c 4 FUZZ.domain.net +cfuzz -w [wordlist] ping -c 4 FUZZ.domain.net ``` Additionnaly it is possible to: * **[Filter results](#filter-results)** * **[Custom displayed field](#displayed-field)** * **[Configure `cfuzz` run](#cfuzz-run-configuration)** +* **[Generate wordlists with AI](#ai-features)** +* **[Use cfuzz as an MCP server](#mcp-server)** ### Filter results @@ -71,31 +73,31 @@ Additionaly, it is possible to filter displayed results: **stdout filters:** ```shell - -omin, --stdout-min filter to only display if stdout characters number is lesser than n - -omax, --stdout-max filter to only display if stdout characters number is greater than n - -oeq, --stdout-equal filter to only display if stdout characters number is equal to n - -ow, --stdout-word filter to only display if stdout cointains specific word + --stdout-min n show only if stdout character count >= n + --stdout-max n show only if stdout character count <= n + --stdout-eq n show only if stdout character count == n + --stdout-word w show only if stdout contains word w (repeatable) ``` **stderr filters:** ```shell - -emin, --stderr-min filter to only display if stderr characters number is lesser than n - -emax, --stderr-max filter to only display if stderr characters number is greater than n - -eeq, --stderr-equal filter to only display if stderr characters number is equal to n - -ew, --stderr-word filter to only display if stderr cointains specific word + --stderr-min n show only if stderr character count >= n + --stderr-max n show only if stderr character count <= n + --stderr-eq n show only if stderr character count == n + --stderr-word w show only if stderr contains word w (repeatable) ``` **execution time filters:** ```shell - -tmin, --time-min filter to only display if exectuion time is shorter than n seconds - -tmax, --time-max filter to only display if exectuion time is longer than n seconds - -teq, --time-equal filter to only display if exectuion time is shorter than n seconds + --time-min n show only if execution time >= n seconds + --time-max n show only if execution time <= n seconds + --time-eq n show only if execution time == n seconds ``` **command exit code filters:** ```shell - --success filter to only display if execution return a zero exit code - --failure filter to only display if execution return a non-zero exit code + --success show only if execution returns exit code 0 + --failure show only if execution returns a non-zero exit code ``` To only display results that don't pass the filter use `-H` or `--hide` flag. @@ -103,26 +105,72 @@ To only display results that don't pass the filter use `-H` or `--hide` flag. ### `cfuzz` run configuration To make cfuzz more flexible and adapt to different constraints, many options are possible: ```shell - -w, --wordlist wordlist used by fuzzer - -d, --delay delay in ms between each thread launching. A thread executes one command. (default: 0) - -k, --keyword keyword used to determine which zone to fuzz (default: FUZZ) - -s, --shell shell to use for execution (default: /bin/bash) - -to, --timeout command execution timeout in s. After reaching it the command is killed. (default: 30) - -i, --input provide command stdin - -if, --stdin-fuzzing fuzz sdtin instead of command line - -m, --spider fuzz multiple keyword places. You must provide as many wordlists as keywords. Provide them in order you want them to be applied - -sw, --stdin-wordlist provide wordlist in cfuzz stdin + -w, --wordlist wordlist file(s) for fuzzing (repeatable with --spider) + -d, --delay delay in ms between goroutine launches (default: 0) + -j, --threads max concurrent workers (default: 50) + -k, --keyword keyword to replace in command (default: FUZZ) + -s, --shell shell to use for execution (default: /bin/bash) + --timeout command execution timeout in seconds (default: 30) + -i, --input provide command stdin + --stdin-fuzzing fuzz stdin instead of command line + -m, --spider fuzz multiple keyword positions (requires multiple -w) + --stdin-wordlist read wordlist from cfuzz stdin ``` ### Displayed field It is also possible to choose which result field is displayed in `cfuzz` output (also possible to use several): ```shell - -oc, --stdout display stdout number of characters - -ec, --stderr display stderr number of characters - -t, --time display execution time - -c, --code display exit code - -Hb, --no-banner do not display banner - -r, --only-word only display words - -f, --full-output display full command execution output (can't be combined with others display mode) + --stdout-chars display stdout character count + --stderr-chars display stderr character count + -t, --time display execution time + -c, --code display exit code + --no-banner hide banner + -r, --only-word print only matched words (no metadata columns) + -f, --full-output display full command execution output (can't be combined with other display modes) ``` + +### AI features + +`cfuzz` integrates with Claude (via the Anthropic API) for two AI-powered workflows. Both require the `ANTHROPIC_API_KEY` environment variable to be set. + +**AI filter** — describe what an interesting result looks like in plain English; `cfuzz` will ask Claude to evaluate each execution result and only show the ones that match: + +```shell +cfuzz -w wordlist.txt --ai-filter "output contains an error about invalid credentials" \ + curl -s http://target/login -d "user=admin&pass=FUZZ" +``` + +**AI wordlist generation** — generate a context-aware wordlist by describing what you need: + +```shell +cfuzz wordlist "default credentials for network switches" +cfuzz wordlist "common web admin paths" -n 50 +``` + +Output is printed to stdout, one entry per line, making it easy to pipe directly into cfuzz: + +```shell +cfuzz wordlist "linux privilege escalation binaries" | \ + cfuzz --stdin-wordlist "sudo -l FUZZ 2>/dev/null | grep -v 'not allowed'" +``` + +### MCP server + +`cfuzz` can run as a [Model Context Protocol](https://modelcontextprotocol.io) server, exposing a `fuzz` tool that any MCP-compatible AI assistant can call: + +```shell +cfuzz mcp +``` + +To register with Claude Desktop, add to `~/.claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "cfuzz": { "command": "cfuzz", "args": ["mcp"] } + } +} +``` + +The `fuzz` tool accepts: `command` (string), `wordlist` (array of strings), and optional `threads`, `timeout`, `success_only`, `stdout_word`, and `ai_filter` parameters. diff --git a/cmd/cfuzz/cfuzz.go b/cmd/cfuzz/cfuzz.go deleted file mode 100644 index 1b7582b..0000000 --- a/cmd/cfuzz/cfuzz.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "log" - - "github.com/ariary/cfuzz/pkg/fuzz" -) - -func main() { - //log.SetFlags(log.Lshortfile) //set default logger - log.SetFlags(0) - - // config & banner - cfg := fuzz.NewConfig() - - if !cfg.HideBanner { - fuzz.Banner() - fuzz.PrintConfig(cfg) - } - - if err := cfg.CheckConfig(); err != nil { - log.Fatal(err) - } - - fuzz.PerformFuzzing(cfg) - -} diff --git a/cmd/cfuzz/main.go b/cmd/cfuzz/main.go new file mode 100644 index 0000000..8d05654 --- /dev/null +++ b/cmd/cfuzz/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "log" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "cfuzz [flags] [command]", + Short: "Fuzz any command line execution", + Long: `cfuzz fuzzes any shell command by replacing a keyword with wordlist entries. + +Put FUZZ in your command, provide a wordlist, and cfuzz runs every substitution concurrently. + +Examples: + cfuzz -w passwords.txt echo FUZZ + cfuzz -w users.txt --success ssh FUZZ@host id + cfuzz wordlist "SSH usernames" | cfuzz --stdin-wordlist "ssh FUZZ@host id"`, + SilenceUsage: true, + SilenceErrors: true, +} + +func main() { + log.SetFlags(0) + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + os.Exit(1) + } +} diff --git a/cmd/cfuzz/mcp.go b/cmd/cfuzz/mcp.go new file mode 100644 index 0000000..d1da697 --- /dev/null +++ b/cmd/cfuzz/mcp.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/ariary/cfuzz/pkg/ai" + "github.com/ariary/cfuzz/pkg/fuzz" + "github.com/ariary/cfuzz/pkg/mcp" + "github.com/spf13/cobra" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Start cfuzz as an MCP server over stdio", + Long: `Start cfuzz as a Model Context Protocol (MCP) server. + +Communicates over stdio using JSON-RPC 2.0 and exposes a "fuzz" tool. + +To register with Claude Desktop, add to ~/.claude/claude_desktop_config.json: + { + "mcpServers": { + "cfuzz": { "command": "cfuzz", "args": ["mcp"] } + } + }`, + SilenceUsage: true, + SilenceErrors: true, + Run: runMCP, +} + +func init() { + rootCmd.AddCommand(mcpCmd) +} + +func runMCP(_ *cobra.Command, _ []string) { + fmt.Fprintln(os.Stderr, "cfuzz MCP server started (stdio)") + mcp.Serve(handleFuzzCall) +} + +func handleFuzzCall(args map[string]any) (string, error) { + cfg := fuzz.DefaultConfig() + cfg.HideBanner = true + cfg.OnlyWord = false + + command, ok := args["command"].(string) + if !ok || command == "" { + return "", fmt.Errorf("command is required") + } + cfg.Command = command + + rawList, ok := args["wordlist"].([]any) + if !ok || len(rawList) == 0 { + return "", fmt.Errorf("wordlist is required and must be a non-empty array") + } + + tmp, err := os.CreateTemp("", "cfuzz-mcp-*.txt") + if err != nil { + return "", fmt.Errorf("failed to create temp wordlist: %w", err) + } + defer os.Remove(tmp.Name()) + for _, entry := range rawList { + if s, ok := entry.(string); ok { + fmt.Fprintln(tmp, s) + } + } + tmp.Close() + cfg.Wordlists = []string{tmp.Name()} + + if v, ok := args["threads"].(float64); ok { + cfg.Threads = int(v) + } + if v, ok := args["timeout"].(float64); ok { + cfg.Timeout = int64(v) + } + if v, ok := args["success_only"].(bool); ok && v { + cfg.Filters = append(cfg.Filters, fuzz.CodeSuccessFilter{Zero: true}) + } + if v, ok := args["stdout_word"].(string); ok && v != "" { + cfg.Filters = append(cfg.Filters, fuzz.StdoutWordFilter{TargetWord: v}) + } + if v, ok := args["ai_filter"].(string); ok && v != "" { + client, err := ai.NewClient() + if err != nil { + return "", fmt.Errorf("ai_filter requires ANTHROPIC_API_KEY: %w", err) + } + criterion := v + cfg.AIFilterFn = func(word, stdout, stderr, code string) bool { + return ai.IsInteresting(client, criterion, word, stdout, stderr, code) + } + } + + var buf strings.Builder + cfg.ResultLogger = log.New(&buf, "", 0) + cfg.DisplayModes = fuzz.BuildDisplayModes(false, false, false, false, false) + + fuzz.PerformFuzzing(cfg) + return buf.String(), nil +} diff --git a/cmd/cfuzz/root.go b/cmd/cfuzz/root.go new file mode 100644 index 0000000..12dd098 --- /dev/null +++ b/cmd/cfuzz/root.go @@ -0,0 +1,145 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/ariary/cfuzz/pkg/ai" + "github.com/ariary/cfuzz/pkg/fuzz" + "github.com/spf13/cobra" +) + +func init() { + f := rootCmd.Flags() + + // Configuration + f.StringArrayP("wordlist", "w", nil, "wordlist file(s) for fuzzing (repeatable with --spider)") + f.StringP("keyword", "k", "FUZZ", "keyword to replace in command") + f.StringP("shell", "s", "/bin/bash", "shell to use for execution") + f.Int64P("delay", "d", 0, "delay in ms between goroutine launches") + f.Int64("timeout", 30, "command execution timeout in seconds") + f.StringP("input", "i", "", "provide command stdin") + f.Bool("stdin-fuzzing", false, "fuzz stdin instead of command line") + f.BoolP("spider", "m", false, "fuzz multiple keyword positions (requires multiple -w)") + f.Bool("stdin-wordlist", false, "read wordlist from cfuzz stdin") + f.IntP("threads", "j", 50, "max concurrent workers") + + // Display + f.Bool("no-banner", false, "hide banner") + f.BoolP("only-word", "r", false, "print only matched words (no metadata columns)") + f.BoolP("hide", "H", false, "show results that do NOT pass the filters") + f.Bool("stdout-chars", false, "display stdout character count") + f.Bool("stderr-chars", false, "display stderr character count") + f.BoolP("time", "t", false, "display execution time") + f.BoolP("code", "c", false, "display exit code") + f.BoolP("full-output", "f", false, "display full command output") + + // Filters — stdout (-1 = not set) + f.Int("stdout-min", -1, "show only if stdout chars >= n") + f.Int("stdout-max", -1, "show only if stdout chars <= n") + f.Int("stdout-eq", -1, "show only if stdout chars == n") + f.StringArray("stdout-word", nil, "show only if stdout contains word (repeatable)") + + // Filters — stderr (-1 = not set) + f.Int("stderr-min", -1, "show only if stderr chars >= n") + f.Int("stderr-max", -1, "show only if stderr chars <= n") + f.Int("stderr-eq", -1, "show only if stderr chars == n") + f.StringArray("stderr-word", nil, "show only if stderr contains word (repeatable)") + + // Filters — time (-1 = not set) + f.Int("time-min", -1, "show only if execution time >= n seconds") + f.Int("time-max", -1, "show only if execution time <= n seconds") + f.Int("time-eq", -1, "show only if execution time == n seconds") + + // Filters — exit code + f.Bool("success", false, "show only commands with exit code 0") + f.Bool("failure", false, "show only commands with non-zero exit code") + + // AI + f.String("ai-filter", "", "AI natural language filter description (requires ANTHROPIC_API_KEY)") + + rootCmd.RunE = runFuzz +} + +func runFuzz(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + cfg := fuzz.DefaultConfig() + + cfg.Wordlists, _ = f.GetStringArray("wordlist") + cfg.Keyword, _ = f.GetString("keyword") + cfg.Shell, _ = f.GetString("shell") + cfg.RoutineDelay, _ = f.GetInt64("delay") + cfg.Timeout, _ = f.GetInt64("timeout") + cfg.Input, _ = f.GetString("input") + cfg.StdinFuzzing, _ = f.GetBool("stdin-fuzzing") + cfg.Multiple, _ = f.GetBool("spider") + cfg.StdinWordlist, _ = f.GetBool("stdin-wordlist") + cfg.Threads, _ = f.GetInt("threads") + cfg.HideBanner, _ = f.GetBool("no-banner") + cfg.OnlyWord, _ = f.GetBool("only-word") + cfg.Hide, _ = f.GetBool("hide") + cfg.FullDisplay, _ = f.GetBool("full-output") + cfg.AIFilter, _ = f.GetString("ai-filter") + + // Command from env or positional args + if cmdEnv := os.Getenv("CFUZZ_CMD"); cmdEnv != "" { + cfg.Command = cmdEnv + } else if len(args) > 0 { + cfg.Command = strings.Join(args, " ") + } + + // Display modes (skipped when --only-word) + if !cfg.OnlyWord { + stdoutChars, _ := f.GetBool("stdout-chars") + stderrChars, _ := f.GetBool("stderr-chars") + showTime, _ := f.GetBool("time") + showCode, _ := f.GetBool("code") + cfg.DisplayModes = fuzz.BuildDisplayModes(stdoutChars, stderrChars, showTime, showCode, cfg.FullDisplay) + } + + // Filters + stdoutMin, _ := f.GetInt("stdout-min") + stdoutMax, _ := f.GetInt("stdout-max") + stdoutEq, _ := f.GetInt("stdout-eq") + stdoutWords, _ := f.GetStringArray("stdout-word") + stderrMin, _ := f.GetInt("stderr-min") + stderrMax, _ := f.GetInt("stderr-max") + stderrEq, _ := f.GetInt("stderr-eq") + stderrWords, _ := f.GetStringArray("stderr-word") + timeMin, _ := f.GetInt("time-min") + timeMax, _ := f.GetInt("time-max") + timeEq, _ := f.GetInt("time-eq") + success, _ := f.GetBool("success") + failure, _ := f.GetBool("failure") + + cfg.Filters = fuzz.BuildFilters( + stdoutMin, stdoutMax, stdoutEq, stdoutWords, + stderrMin, stderrMax, stderrEq, stderrWords, + timeMin, timeMax, timeEq, + success, failure, + ) + + if cfg.AIFilter != "" { + aiClient, err := ai.NewClient() + if err != nil { + return fmt.Errorf("--ai-filter requires ANTHROPIC_API_KEY: %w", err) + } + criterion := cfg.AIFilter + cfg.AIFilterFn = func(word, stdout, stderr, code string) bool { + return ai.IsInteresting(aiClient, criterion, word, stdout, stderr, code) + } + } + + if !cfg.HideBanner { + fuzz.Banner() + fuzz.PrintConfig(cfg) + } + + if err := cfg.CheckConfig(); err != nil { + return err + } + + fuzz.PerformFuzzing(cfg) + return nil +} diff --git a/cmd/cfuzz/wordlist.go b/cmd/cfuzz/wordlist.go new file mode 100644 index 0000000..8180b12 --- /dev/null +++ b/cmd/cfuzz/wordlist.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "os" + + "github.com/ariary/cfuzz/pkg/ai" + "github.com/spf13/cobra" +) + +var wordlistCmd = &cobra.Command{ + Use: "wordlist \"description\"", + Short: "Generate a wordlist using AI", + Long: `Generate a context-aware wordlist by describing what you need. +Output is printed to stdout, one entry per line, suitable for piping into cfuzz. + +Requires ANTHROPIC_API_KEY. + +Examples: + cfuzz wordlist "default credentials for network switches" + cfuzz wordlist "common web admin paths" -n 50 | cfuzz --stdin-wordlist "curl -s http://target/FUZZ"`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: runWordlist, +} + +func init() { + wordlistCmd.Flags().IntP("count", "n", 0, "hint for number of entries to generate (0 = let AI decide)") + rootCmd.AddCommand(wordlistCmd) +} + +func runWordlist(cmd *cobra.Command, args []string) error { + client, err := ai.NewClient() + if err != nil { + return fmt.Errorf("cfuzz wordlist requires ANTHROPIC_API_KEY: %w", err) + } + + count, _ := cmd.Flags().GetInt("count") + if err := ai.GenerateWordlist(client, args[0], count); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return nil +} diff --git a/go.mod b/go.mod index 2ee661f..f86bbf9 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,19 @@ module github.com/ariary/cfuzz -go 1.17 +go 1.23.0 -require github.com/ariary/go-utils v1.0.16 +require ( + github.com/anthropics/anthropic-sdk-go v1.37.0 + github.com/ariary/go-utils v1.0.16 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + golang.org/x/sync v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..551cba8 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/anthropics/anthropic-sdk-go v1.37.0 h1:yBKUaBG3TCRb6das/Q5qNB9Fsafon09gu2yYVgvapKE= +github.com/anthropics/anthropic-sdk-go v1.37.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8= +github.com/ariary/go-utils v1.0.16 h1:kNyr1uGbog9aqBS8Uth1AZSfeNR4Hgv1O8UXDpnomfs= +github.com/ariary/go-utils v1.0.16/go.mod h1:3gpTQLFaOsiZkHnCOAadPiY75nnETNYG7vOq5/o9vRk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/ai/client.go b/pkg/ai/client.go new file mode 100644 index 0000000..aa7df00 --- /dev/null +++ b/pkg/ai/client.go @@ -0,0 +1,22 @@ +package ai + +import ( + "errors" + "os" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +const model = "claude-haiku-4-5-20251001" + +// NewClient creates an Anthropic client from ANTHROPIC_API_KEY. +// Returns an error if the key is not set. +func NewClient() (*anthropic.Client, error) { + key := os.Getenv("ANTHROPIC_API_KEY") + if key == "" { + return nil, errors.New("ANTHROPIC_API_KEY is not set") + } + c := anthropic.NewClient(option.WithAPIKey(key)) + return &c, nil +} diff --git a/pkg/ai/filter.go b/pkg/ai/filter.go new file mode 100644 index 0000000..5b46aaf --- /dev/null +++ b/pkg/ai/filter.go @@ -0,0 +1,52 @@ +package ai + +import ( + "context" + "fmt" + "os" + "strings" + + anthropic "github.com/anthropics/anthropic-sdk-go" +) + +// IsInteresting sends the result to the LLM and asks if it matches the criterion. +// Returns true if the LLM responds YES, or if an API error occurs (fail-open). +func IsInteresting(client *anthropic.Client, criterion, word, stdout, stderr, code string) bool { + prompt := fmt.Sprintf(`You are evaluating command execution results for a security researcher. +Criterion: %s + +WORD: %s +STDOUT: %s +STDERR: %s +EXIT CODE: %s + +Does this result match the criterion? Reply with exactly YES or NO.`, + criterion, word, + truncate(stdout, 500), + truncate(stderr, 500), + code, + ) + + msg, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: anthropic.Model(model), + MaxTokens: 16, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)), + }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "ai-filter error: %v\n", err) + return true // fail-open: show the result + } + if len(msg.Content) == 0 { + return true + } + return strings.TrimSpace(msg.Content[0].Text) == "YES" +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "…" +} diff --git a/pkg/ai/wordlist.go b/pkg/ai/wordlist.go new file mode 100644 index 0000000..90d0ea6 --- /dev/null +++ b/pkg/ai/wordlist.go @@ -0,0 +1,39 @@ +package ai + +import ( + "context" + "fmt" + "os" + + anthropic "github.com/anthropics/anthropic-sdk-go" +) + +// GenerateWordlist calls the LLM to produce a wordlist for the given description +// and prints each line to stdout. count hints at desired size (0 = let LLM decide). +func GenerateWordlist(client *anthropic.Client, description string, count int) error { + sizeHint := "" + if count > 0 { + sizeHint = fmt.Sprintf(" Generate approximately %d entries.", count) + } + + prompt := fmt.Sprintf(`Generate a wordlist for: %s%s +Output only the words or phrases, one per line. No numbering, no explanation, no blank lines, no headers.`, + description, sizeHint, + ) + + msg, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: anthropic.Model(model), + MaxTokens: 2048, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)), + }, + }) + if err != nil { + return fmt.Errorf("wordlist generation failed: %w", err) + } + if len(msg.Content) == 0 { + return fmt.Errorf("empty response from API") + } + fmt.Fprintln(os.Stdout, msg.Content[0].Text) + return nil +} diff --git a/pkg/fuzz/config.go b/pkg/fuzz/config.go index 70d2fc5..ede332a 100644 --- a/pkg/fuzz/config.go +++ b/pkg/fuzz/config.go @@ -2,18 +2,23 @@ package fuzz import ( "errors" - "flag" - "fmt" "log" "os" "strconv" "strings" ) -type wordlists []string +// Wordlists is a slice of wordlist file paths with a String() method for formatting. +type Wordlists []string +// String returns a comma-separated string representation of the wordlists. +func (w Wordlists) String() string { + return strings.Join(w, ",") +} + +// Config holds all runtime configuration for a cfuzz run. type Config struct { - Wordlists wordlists + Wordlists Wordlists Keyword string Command string RoutineDelay int64 @@ -23,6 +28,13 @@ type Config struct { StdinFuzzing bool Multiple bool StdinWordlist bool + Threads int + AIFilter string + OnlyWord bool + // AIFilterFn is an optional callback that returns true if the result should be shown. + // Used to wire in AI filtering without importing pkg/ai from pkg/fuzz. + // When nil, no AI filtering is applied. + AIFilterFn func(word, stdout, stderr, code string) bool DisplayModes []DisplayMode FullDisplay bool HideBanner bool @@ -31,411 +43,147 @@ type Config struct { ResultLogger *log.Logger } -var usage = `Usage of cfuzz: cfuzz [flags values] [command] or cfuzz [flags values] [command] with CFUZZ_CMD environment variable set -Fuzz command line execution and filter results - -CONFIGURATION - -w, --wordlist wordlist used by fuzzer - -d, --delay delay in ms between each thread launching. A thread executes the command. (default: 0) - -k, --keyword keyword used to determine which zone to fuzz (default: FUZZ) - -s, --shell shell to use for execution (default: /bin/bash) - -to, --timeout command execution timeout in s. After reaching it the command is killed. (default: 30) - -i, --input provide command stdin - -if, --stdin-fuzzing fuzz sdtin instead of command line - -m, --spider fuzz multiple keyword places. You must provide as many wordlists as keywords. Provide them in order you want them to be applied. - -sw, --stdin-wordlist provide wordlist in cfuzz stdin - -DISPLAY - -oc, --stdout display stdout number of characters - -ec, --stderr display stderr number of characters - -t, --time display execution time - -c, --code display exit code - -Hb, --no-banner do not display banner - -r, --only-word only display words (from wordlist) - -f, --full-output display full command execution output (can't be combined with others display mode) - -FILTER - -H, --hide only display results that don't pass the filters - - STDOUT: - -omin, --stdout-min filter to only display if stdout characters number is lesser than n - -omax, --stdout-max filter to only display if stdout characters number is greater than n - -oeq, --stdout-equal filter to only display if stdout characters number is equal to n - -ow, --stdout-word filter to only display if stdout cointains specific word - - STDERR: - -emin, --stderr-min filter to only display if stderr characters number is lesser than n - -emax, --stderr-max filter to only display if stderr characters number is greater than n - -eeq, --stderr-equal filter to only display if stderr characters number is equal to n - -ew, --stderr-word filter to only display if stderr cointains specific word - - TIME: - -tmin, --time-min filter to only display if exectuion time is shorter than n seconds - -tmax, --time-max filter to only display if exectuion time is longer than n seconds - -teq, --time-equal filter to only display if exectuion time is shorter than n seconds - - CODE: - --success filter to only display if execution return a zero exit code - --failure filter to only display if execution return a non-zero exit code - - -h, --help prints help information -` - -func (i *wordlists) String() string { - - return strings.Join(*i, ",") -} - -func (i *wordlists) Set(value string) error { - *i = append(*i, value) - return nil -} - -// NewConfig create Config instance -func NewConfig() Config { - // default value - config := Config{Keyword: "FUZZ"} - - //logger - // minwidth, tabwidth, padding, padchar, flags - config.ResultLogger = log.New(os.Stdout, "", 0) - - // CONFIGURATION - // flag wordlist - flag.Var(&config.Wordlists, "wordlist", "wordlist used by fuzzer") - flag.Var(&config.Wordlists, "w", "wordlist used by fuzzer") - - // flag keyword - flag.StringVar(&config.Keyword, "keyword", "FUZZ", "keyword use to determine which zone to fuzz") - flag.StringVar(&config.Keyword, "k", "FUZZ", "keyword use to determine which zone to fuzz") - - // flag shell - flag.StringVar(&config.Shell, "shell", "/bin/bash", "shell to use for execution") - flag.StringVar(&config.Shell, "s", "/bin/bash", "shell to use for execution") - - // flag RoutineDelay - flag.Int64Var(&config.RoutineDelay, "d", 0, "delay in ms between each thread launching. A thread execute the command. (default: 0)") - flag.Int64Var(&config.RoutineDelay, "delay", 0, "delay in ms between each thread launching. A thread execute the command. (default: 0)") - - //flag timeout - flag.Int64Var(&config.Timeout, "to", 30, "Command execution timeout in s. After reaching it the command is killed. (default: 30)") - flag.Int64Var(&config.Timeout, "timeout", 30, "Command execution timeout in s. After reaching it the command is killed. (default: 30)") - - // flag input - flag.StringVar(&config.Input, "input", "", "fuzz stdin") - flag.StringVar(&config.Input, "i", "", "fuzz stdin") - - // flag stdin-fuzzing - flag.BoolVar(&config.StdinFuzzing, "stdin-fuzzing", false, "fuzz stdin") - flag.BoolVar(&config.StdinFuzzing, "if", false, "fuzz stdin") - - // flag spider - flag.BoolVar(&config.Multiple, "spider", false, "fuzz multiple keyword") - flag.BoolVar(&config.Multiple, "m", false, "fuzz multiple keyword") - - // flag stdin wordlist - flag.BoolVar(&config.StdinWordlist, "stdin-wordlist", false, "wordlist provided in stdin") - flag.BoolVar(&config.StdinWordlist, "sw", false, "wordlist provided in stdin") - - // DISPLAY MODE - - // flag hide banner - flag.BoolVar(&config.HideBanner, "Hb", false, "hide banner") - flag.BoolVar(&config.HideBanner, "no-banner", false, "hide banner") - - // flag only word display - var noDisplay bool - flag.BoolVar(&noDisplay, "r", false, "print only word") - flag.BoolVar(&noDisplay, "only-word", false, "print only word") - - // flag hide - flag.BoolVar(&config.Hide, "H", false, "hide fields that pass the filter") - flag.BoolVar(&config.Hide, "hide", false, "hide fields that pass the filter") - - var stdoutDisplay bool - flag.BoolVar(&stdoutDisplay, "oc", false, "display command execution number of characters in stdout.") - flag.BoolVar(&stdoutDisplay, "stdout-characters", false, "display execution command number of characters in stdout.") - - var stderrDisplay bool - flag.BoolVar(&stderrDisplay, "ec", false, "display command execution number of characters in stderr.") - flag.BoolVar(&stderrDisplay, "stderr-characters", false, "display execution command number of characters in stderr.") - - var timeDisplay bool - flag.BoolVar(&timeDisplay, "t", false, "display command execution time.") - flag.BoolVar(&timeDisplay, "time", false, "display command execution time.") - - var codeDisplay bool - flag.BoolVar(&codeDisplay, "c", false, "display command execution exit code.") - flag.BoolVar(&codeDisplay, "code", false, "display command execution exit code.") - - flag.BoolVar(&config.FullDisplay, "f", false, "display full command execution output") - flag.BoolVar(&config.FullDisplay, "full-output", false, "display full command execution output") - - // FILTERS - var success, failure bool - flag.BoolVar(&success, "success", false, "filter to display only command with exit code 0.") - flag.BoolVar(&failure, "failure", false, "filter to display only command with a non-zero exit .") - - parseFilters(&config) - - flag.Usage = func() { fmt.Print(usage) } - flag.Parse() - - parseSpecialFilters(&config, success, failure) //success and failure need flags to be parse before - - // command - if cmdEnv := os.Getenv("CFUZZ_CMD"); cmdEnv != "" { - config.Command = cmdEnv - } else if flag.NArg() > 0 { - cmdArg := strings.Join(flag.Args(), " ") - config.Command = cmdArg - } - - // parse display mode - if !noDisplay { - config.DisplayModes = parseDisplayMode(&config, stdoutDisplay, stderrDisplay, timeDisplay, codeDisplay) +// DefaultConfig returns a Config with sensible defaults and a stdout logger. +func DefaultConfig() Config { + return Config{ + Keyword: "FUZZ", + Shell: "/bin/bash", + Timeout: 30, + Threads: 50, + ResultLogger: log.New(os.Stdout, "", 0), } - - return config } -//CheckConfig: assert that all required fields are present in config, and are adequate to cfuzz run +// CheckConfig validates that all required fields are present and consistent. func (c *Config) CheckConfig() error { if len(c.Wordlists) == 0 && !c.StdinWordlist { - return errors.New("No wordlist provided. Please indicate a wordlist to use for fuzzing (-w,--wordlist) or provide it trough stdin (--stdin-wordlist)") + return errors.New("no wordlist provided: use -w/--wordlist or --stdin-wordlist") } if len(c.Wordlists) != 0 && c.StdinWordlist { - return errors.New("-w/--wordlist can't be used with -sw/--stdin-wordlist flag") + return errors.New("-w/--wordlist cannot be combined with --stdin-wordlist") } - if c.Keyword == "" { - return errors.New("Fuzzing Keyword can't be empty string") + return errors.New("fuzzing keyword cannot be empty") } if c.Command == "" { - return errors.New("No command provided. Please indicate it using environment variable CFUZZ_CMD or cfuzz [flag:value] [command]") + return errors.New("no command provided: set CFUZZ_CMD or pass command as argument") } - - //--spider & --stdin-wordlist incompatible if c.Multiple && c.StdinWordlist { - return errors.New("--spider can't be used with -sw/--stdin-wordlist flag") + return errors.New("--spider cannot be combined with --stdin-wordlist") } - if c.Multiple && len(c.Wordlists) < 2 { - return errors.New("Only 1 wordlist has been provided with multiple wordlists/keyword mode (-m/--spider). use this option only with several wordlists") - } else if !c.Multiple && len(c.Wordlists) > 1 { - return errors.New("Several wordlists have been submitted. Please use -m flag to use more than one wordlist/keyword") + return errors.New("--spider requires at least 2 wordlists") + } + if !c.Multiple && len(c.Wordlists) > 1 { + return errors.New("multiple wordlists provided without --spider") } - if c.FullDisplay && len(c.DisplayModes) > 0 { - return errors.New("-f/full-output can't be used with other display mode:" + c.DisplayModes[0].Name()) //only give the first one for example + return errors.New("--full-output cannot be combined with other display modes: " + c.DisplayModes[0].Name()) } - // check field consistency - err := checkKeywordsPresence(c) - - return err + return checkKeywordsPresence(c) } -//checkKeywordsPresence: check the consistency between flag and keyword presence (ie Keyword is present in stdin or command and if --spider check -//there are as many keyword than wordlist) func checkKeywordsPresence(c *Config) error { if c.StdinFuzzing { - if c.Multiple { //stdin + multiple - keywordNum := strings.Count(c.Input+c.Command, c.Keyword) - if keywordNum != len(c.Wordlists) { - return errors.New("Please provide as many wordlists as keyword. keyword:" + c.Keyword + " input:" + c.Input + " command:" + c.Command + "wordlist number:" + strconv.Itoa(len(c.Wordlists))) + if c.Multiple { + n := strings.Count(c.Input+c.Command, c.Keyword) + if n != len(c.Wordlists) { + return errors.New("keyword count (" + strconv.Itoa(n) + ") must match wordlist count (" + strconv.Itoa(len(c.Wordlists)) + ")") } - } else if !strings.Contains(c.Input, c.Keyword) { //stdin simple - return errors.New("Fuzzing keyword has not been found in stdin. keyword:" + c.Keyword + " input:" + c.Input) - } else { - return nil + } else if !strings.Contains(c.Input, c.Keyword) { + return errors.New("keyword " + c.Keyword + " not found in stdin input: " + c.Input) } - } else if c.Multiple { // multiple w/o stdin - keywordNum := strings.Count(c.Command, c.Keyword) - if keywordNum != len(c.Wordlists) { - return errors.New("Please provide as many wordlists as keyword. keyword:" + c.Keyword + " command:" + c.Command + "wordlist number:" + strconv.Itoa(len(c.Wordlists))) + } else if c.Multiple { + n := strings.Count(c.Command, c.Keyword) + if n != len(c.Wordlists) { + return errors.New("keyword count (" + strconv.Itoa(n) + ") must match wordlist count (" + strconv.Itoa(len(c.Wordlists)) + ")") } - } else if !strings.Contains(c.Command, c.Keyword) { //simple w/o stdin - return errors.New("Fuzzing keyword has not been found in command. keyword:" + c.Keyword + " command:" + c.Command) + } else if !strings.Contains(c.Command, c.Keyword) { + return errors.New("keyword " + c.Keyword + " not found in command: " + c.Command) } return nil } -//parseDisplayMode: Return array of display mode interface chosen with flags. If none, default is stdout characters display mode -func parseDisplayMode(c *Config, stdout bool, stderr bool, time bool, code bool) (modes []DisplayMode) { +// BuildDisplayModes returns the display mode slice from parsed flag values. +// When fullDisplay is true, returns nil (PrintExec handles full display separately). +// When no flags are set, defaults to stdout character count. +func BuildDisplayModes(stdout, stderr, showTime, code, fullDisplay bool) []DisplayMode { + if fullDisplay { + return nil + } + var modes []DisplayMode if stdout { modes = append(modes, StdoutDisplay{}) } if stderr { modes = append(modes, StderrDisplay{}) } - if time { + if showTime { modes = append(modes, TimeDisplay{}) } if code { modes = append(modes, CodeDisplay{}) } - - //default, if none && not full display - if !c.FullDisplay { - if len(modes) == 0 { - stdoutDisplay := StdoutDisplay{} - modes = []DisplayMode{stdoutDisplay} - } + if len(modes) == 0 { + modes = []DisplayMode{StdoutDisplay{}} } - return modes } -// parseFilters: parse all flags and determine the filters, add them in the config struct given in parameter -func parseFilters(config *Config) { - // stdout filters - maxS := []string{"omax", "stdout-max"} - for i := 0; i < len(maxS); i++ { - flag.Func(maxS[i], "filter to display only results with less than n characters", func(max string) error { - n, err := strconv.Atoi(max) - if err != nil { - return err - } - filter := StdoutMaxFilter{Max: n} - config.Filters = append(config.Filters, filter) - return nil - }) - } +// BuildFilters returns the filter slice from parsed flag values. +// Use -1 as sentinel for "not set" on all int parameters. +func BuildFilters( + stdoutMin, stdoutMax, stdoutEq int, + stdoutWords []string, + stderrMin, stderrMax, stderrEq int, + stderrWords []string, + timeMin, timeMax, timeEq int, + success, failure bool, +) []Filter { + var filters []Filter - minS := []string{"omin", "stdout-min"} - for i := 0; i < len(minS); i++ { - flag.Func(minS[i], "filter to display only results with more than n characters", func(min string) error { - n, err := strconv.Atoi(min) - if err != nil { - return err - } - filter := StdoutMinFilter{Min: n} - config.Filters = append(config.Filters, filter) - return nil - }) + if stdoutMin >= 0 { + filters = append(filters, StdoutMinFilter{Min: stdoutMin}) } - - eqS := []string{"oeq", "stdout-equal"} - for i := 0; i < len(eqS); i++ { - flag.Func(eqS[i], "filter to display only results with exactly n characters", func(eq string) error { - n, err := strconv.Atoi(eq) - if err != nil { - return err - } - filter := StdoutEqFilter{Eq: n} - config.Filters = append(config.Filters, filter) - return nil - }) + if stdoutMax >= 0 { + filters = append(filters, StdoutMaxFilter{Max: stdoutMax}) } - - wordS := []string{"ow", "stdout-word"} - for i := 0; i < len(wordS); i++ { - flag.Func(wordS[i], "filter to display only results cointaing specific in stdout", func(word string) error { - filter := StdoutWordFilter{TargetWord: word} - config.Filters = append(config.Filters, filter) - return nil - }) + if stdoutEq >= 0 { + filters = append(filters, StdoutEqFilter{Eq: stdoutEq}) } - - // stderr filters - emaxS := []string{"emax", "stderr-max"} - for i := 0; i < len(emaxS); i++ { - flag.Func(emaxS[i], "filter to display only results with less than n characters", func(max string) error { - n, err := strconv.Atoi(max) - if err != nil { - return err - } - filter := StderrMaxFilter{Max: n} - config.Filters = append(config.Filters, filter) - return nil - }) + for _, w := range stdoutWords { + filters = append(filters, StdoutWordFilter{TargetWord: w}) } - eminS := []string{"emin", "stderr-min"} - for i := 0; i < len(emaxS); i++ { - flag.Func(eminS[i], "filter to display only results with more than n characters", func(min string) error { - n, err := strconv.Atoi(min) - if err != nil { - return err - } - filter := StderrMinFilter{Min: n} - config.Filters = append(config.Filters, filter) - return nil - }) + if stderrMin >= 0 { + filters = append(filters, StderrMinFilter{Min: stderrMin}) } - - eeqS := []string{"eeq", "stderr-equal"} - for i := 0; i < len(eeqS); i++ { - flag.Func(eeqS[i], "filter to display only results with exactly n characters", func(eq string) error { - n, err := strconv.Atoi(eq) - if err != nil { - return err - } - filter := StderrEqFilter{Eq: n} - config.Filters = append(config.Filters, filter) - return nil - }) + if stderrMax >= 0 { + filters = append(filters, StderrMaxFilter{Max: stderrMax}) } - - ewordS := []string{"ew", "stderr-word"} - for i := 0; i < len(ewordS); i++ { - flag.Func(ewordS[i], "filter to display only results cointaing specific in stderr", func(word string) error { - filter := StderrWordFilter{TargetWord: word} - config.Filters = append(config.Filters, filter) - return nil - }) + if stderrEq >= 0 { + filters = append(filters, StderrEqFilter{Eq: stderrEq}) } - - // time filters - tmaxS := []string{"tmax", "time-max"} - for i := 0; i < len(tmaxS); i++ { - flag.Func(tmaxS[i], "filter to display only results with a time lesser than n seconds", func(max string) error { - n, err := strconv.Atoi(max) - if err != nil { - return err - } - filter := TimeMaxFilter{Max: n} - config.Filters = append(config.Filters, filter) - return nil - }) + for _, w := range stderrWords { + filters = append(filters, StderrWordFilter{TargetWord: w}) } - tminS := []string{"tmin", "time-min"} - for i := 0; i < len(tminS); i++ { - flag.Func(tminS[i], "filter to display only results with a time greater than n seconds", func(min string) error { - n, err := strconv.Atoi(min) - if err != nil { - return err - } - filter := TimeMinFilter{Min: n} - config.Filters = append(config.Filters, filter) - return nil - }) + if timeMin >= 0 { + filters = append(filters, TimeMinFilter{Min: timeMin}) } - - teqS := []string{"teq", "time-equal"} - for i := 0; i < len(teqS); i++ { - flag.Func(teqS[i], "filter to display only results with a time equal to n seconds", func(eq string) error { - n, err := strconv.Atoi(eq) - if err != nil { - return err - } - filter := TimeEqFilter{Eq: n} - config.Filters = append(config.Filters, filter) - return nil - }) + if timeMax >= 0 { + filters = append(filters, TimeMaxFilter{Max: timeMax}) + } + if timeEq >= 0 { + filters = append(filters, TimeEqFilter{Eq: timeEq}) } -} - -// parseSpecialFilters: parse success and failure flags that need to flag be parsed before -func parseSpecialFilters(config *Config, success bool, failure bool) { if success { - filter := CodeSuccessFilter{Zero: true} - config.Filters = append(config.Filters, filter) + filters = append(filters, CodeSuccessFilter{Zero: true}) } if failure { - filter := CodeSuccessFilter{Zero: false} - config.Filters = append(config.Filters, filter) + filters = append(filters, CodeSuccessFilter{Zero: false}) } + + return filters } diff --git a/pkg/fuzz/fuzz.go b/pkg/fuzz/fuzz.go index e2eedbb..1ba17a4 100644 --- a/pkg/fuzz/fuzz.go +++ b/pkg/fuzz/fuzz.go @@ -4,11 +4,13 @@ import ( "bufio" "bytes" "context" + "fmt" "log" "os" "os/exec" "strings" "sync" + "sync/atomic" "time" ) @@ -42,12 +44,38 @@ func getLines(filename string) (wordlist []string) { return wordlist } +// countLines counts newlines in a file without loading it fully into memory. +func countLines(filename string) int { + f, err := os.Open(filename) + if err != nil { + return 0 + } + defer f.Close() + scanner := bufio.NewScanner(f) + n := 0 + for scanner.Scan() { + n++ + } + return n +} + +// printProgress writes a progress indicator to stderr using \r to overwrite in place. +// total == 0 means unknown (stdin mode). +func printProgress(done, total int64) { + if total > 0 { + pct := float64(done) / float64(total) * 100 + fmt.Fprintf(os.Stderr, "\r[%d/%d] %.1f%% ", done, total, pct) + } else { + fmt.Fprintf(os.Stderr, "\r[%d done] ", done) + } +} + //cartesianProduct: take two different string slices and return the cartesian product of both func cartesianProduct(list1 []string, list2 []string) (product [][]string) { - product = make([][]string, len(list1)*(len(list2)-1)) + product = make([][]string, len(list1)*len(list2)) productIndex := 0 - for i := 0; i < len(list1); i++ { //for each item of first list - for j := 1; j < len(list2); j++ { //couple it with other + for i := range list1 { + for j := range list2 { product[productIndex] = append(product[productIndex], list1[i]) product[productIndex] = append(product[productIndex], list2[j]) productIndex++ @@ -58,10 +86,10 @@ func cartesianProduct(list1 []string, list2 []string) (product [][]string) { //cartesianProductPlusPlus: Perform cartesian product between a slice of string slice and a string slice. Beware: complexity -> quadratic func cartesianProductPlusPlus(list1 [][]string, list2 []string) (product [][]string) { - product = make([][]string, len(list1)*(len(list2))) + product = make([][]string, len(list1)*len(list2)) productIndex := 0 - for i := 0; i < len(list1); i++ { //for each item of first list - for j := 0; j < len(list2); j++ { //couple it with other + for i := range list1 { + for j := range list2 { product[productIndex] = append(product[productIndex], list1[i]...) product[productIndex] = append(product[productIndex], list2[j]) productIndex++ @@ -70,62 +98,90 @@ func cartesianProductPlusPlus(list1 [][]string, list2 []string) (product [][]str return product } -//PerformFuzzing: Exec specific crafted command for each wordlist file line read +// PerformFuzzing executes the fuzz run over the configured wordlist(s). +// Concurrency is limited to cfg.Threads goroutines via a semaphore channel. +// Progress is printed to stderr unless cfg.OnlyWord is true. func PerformFuzzing(cfg Config) { - // read wordlist - if !cfg.Multiple { /////////KEEP THIS ITERATION IF SIMPLE (not multiple) => AVOID BROWSING THE WORDLIST TWICE + sem := make(chan struct{}, cfg.Threads) + showProgress := !cfg.OnlyWord + + if !cfg.Multiple { var scanner *bufio.Scanner - if cfg.StdinWordlist { //wordlist from stdin + var total int64 + + if cfg.StdinWordlist { scanner = bufio.NewScanner(os.Stdin) - } else { //wordlist from filename + } else { wordlist, err := os.Open(cfg.Wordlists[0]) if err != nil { log.Fatal(err) } defer wordlist.Close() scanner = bufio.NewScanner(wordlist) + total = int64(countLines(cfg.Wordlists[0])) } var wg sync.WaitGroup - // Caveat: Scanner will error with lines longer than 65536 characters. cf https://stackoverflow.com/questions/8757389/reading-a-file-line-by-line-in-go + var doneCount atomic.Int64 + for scanner.Scan() { time.Sleep(time.Duration(cfg.RoutineDelay) * time.Millisecond) + word := scanner.Text() + sem <- struct{}{} wg.Add(1) - substituteStr := scanner.Text() - - go Exec(cfg, &wg, []string{substituteStr}) + go func(w string) { + defer func() { + <-sem + n := doneCount.Add(1) + if showProgress { + printProgress(n, total) + } + }() + Exec(cfg, &wg, []string{w}) + }(word) } - wg.Wait() + if showProgress { + fmt.Fprintln(os.Stderr) + } if err := scanner.Err(); err != nil { log.Fatal(err) } - } else { //multiple - //construct lists of word containing in wordlist + } else { var wordlists [][]string - for i := 0; i < len(cfg.Wordlists); i++ { - wordlists = append(wordlists, getLines(cfg.Wordlists[i])) + for _, wlPath := range cfg.Wordlists { + wordlists = append(wordlists, getLines(wlPath)) } - //Browse list substitutes := cartesianProduct(wordlists[0], wordlists[1]) for i := 2; i < len(wordlists); i++ { substitutes = cartesianProductPlusPlus(substitutes, wordlists[i]) - } + total := int64(len(substitutes)) var wg sync.WaitGroup + var doneCount atomic.Int64 - for i := 0; i < len(substitutes); i++ { + for _, subs := range substitutes { + sem <- struct{}{} wg.Add(1) - go Exec(cfg, &wg, substitutes[i]) + go func() { + defer func() { + <-sem + n := doneCount.Add(1) + if showProgress { + printProgress(n, total) + } + }() + Exec(cfg, &wg, subs) + }() } - wg.Wait() - + if showProgress { + fmt.Fprintln(os.Stderr) + } } - } //Exec: exec the new command and send result to print function @@ -141,10 +197,9 @@ func Exec(cfg Config, wg *sync.WaitGroup, substitutesStr []string) { nCommand := cfg.Command input := cfg.Input - for i := 0; i < len(substitutesStr); i++ { - nCommand = strings.Replace(nCommand, cfg.Keyword, substitutesStr[i], mode) - - input = strings.Replace(input, cfg.Keyword, substitutesStr[i], mode) + for _, sub := range substitutesStr { + nCommand = strings.Replace(nCommand, cfg.Keyword, sub, mode) + input = strings.Replace(input, cfg.Keyword, sub, mode) } // Create a new context and add a timeout to it @@ -191,24 +246,29 @@ func Exec(cfg Config, wg *sync.WaitGroup, substitutesStr []string) { PrintExec(cfg, result) } -// PrintExec: Print execution result according to configuration and filter +// PrintExec prints execution result according to configuration and filters. func PrintExec(cfg Config, result ExecResult) { if cfg.FullDisplay { PrintFullExecOutput(cfg, result) return - } else { + } - for i := 0; i < len(cfg.Filters); i++ { - if cfg.Filters[i].IsOk(result) == cfg.Hide { - return //don't display it - } + for _, filter := range cfg.Filters { + if filter.IsOk(result) == cfg.Hide { + return } - // display + } - var fields []string - for i := 0; i < len(cfg.DisplayModes); i++ { - fields = append(fields, cfg.DisplayModes[i].DisplayString(result)) + // AI filter (optional, wired from cmd layer to keep pkg/fuzz AI-free) + if cfg.AIFilterFn != nil { + if !cfg.AIFilterFn(result.Substitute, result.Stdout, result.Stderr, result.Code) { + return } - PrintLine(cfg, result.Substitute, fields...) } + + var fields []string + for _, mode := range cfg.DisplayModes { + fields = append(fields, mode.DisplayString(result)) + } + PrintLine(cfg, result.Substitute, fields...) } diff --git a/pkg/fuzz/fuzz_test.go b/pkg/fuzz/fuzz_test.go new file mode 100644 index 0000000..e5f207a --- /dev/null +++ b/pkg/fuzz/fuzz_test.go @@ -0,0 +1,90 @@ +package fuzz + +import ( + "fmt" + "log" + "os" + "sort" + "strings" + "sync" + "testing" +) + +func TestCartesianProduct_IncludesAllCombinations(t *testing.T) { + list1 := []string{"a", "b"} + list2 := []string{"1", "2", "3"} + + result := cartesianProduct(list1, list2) + + if len(result) != 6 { + t.Fatalf("expected 6 combinations, got %d", len(result)) + } + + pairs := make([]string, len(result)) + for i, pair := range result { + pairs[i] = strings.Join(pair, ":") + } + sort.Strings(pairs) + + want := []string{"a:1", "a:2", "a:3", "b:1", "b:2", "b:3"} + for i, w := range want { + if pairs[i] != w { + t.Errorf("index %d: want %q got %q", i, w, pairs[i]) + } + } +} + +func TestCartesianProduct_FirstEntryOfList2Included(t *testing.T) { + list1 := []string{"user"} + list2 := []string{"firstpass", "secondpass"} + + result := cartesianProduct(list1, list2) + + if len(result) != 2 { + t.Fatalf("expected 2 combinations, got %d", len(result)) + } + if result[0][1] != "firstpass" { + t.Errorf("expected first list2 entry to be included, got %q", result[0][1]) + } +} + +type syncWriter struct { + mu sync.Mutex + buf strings.Builder +} + +func (w *syncWriter) Write(b []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.buf.Write(b) +} + +func TestPerformFuzzing_ProcessesAllWords(t *testing.T) { + tmp, err := os.CreateTemp("", "cfuzz-wl-*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmp.Name()) + + const wordCount = 20 + for i := range wordCount { + fmt.Fprintf(tmp, "word%d\n", i) + } + tmp.Close() + + sw := &syncWriter{} + cfg := DefaultConfig() + cfg.HideBanner = true + cfg.Threads = 3 + cfg.Command = "echo FUZZ" + cfg.Wordlists = []string{tmp.Name()} + cfg.DisplayModes = BuildDisplayModes(false, false, false, false, false) + cfg.ResultLogger = log.New(sw, "", 0) + + PerformFuzzing(cfg) + + lines := strings.Split(strings.TrimSpace(sw.buf.String()), "\n") + if len(lines) != wordCount { + t.Errorf("expected %d result lines, got %d:\n%s", wordCount, len(lines), sw.buf.String()) + } +} diff --git a/pkg/fuzz/output.go b/pkg/fuzz/output.go index 6404167..da4889b 100644 --- a/pkg/fuzz/output.go +++ b/pkg/fuzz/output.go @@ -43,7 +43,7 @@ func PrintConfig(cfg Config) { fmt.Println() PrintLine(cfg, "command fuzzed:", cfg.Command) if len(cfg.Wordlists) != 0 { - PrintLine(cfg, "wordlist:", cfg.Wordlists.String()) + PrintLine(cfg, "wordlist:", strings.Join(cfg.Wordlists, ",")) } else if cfg.StdinWordlist { PrintLine(cfg, "wordlist:", "from stdin") } @@ -72,11 +72,13 @@ func PrintLine(cfg Config, value string, element ...string) { // // minwidth, tabwidth, padding, padchar, flags tabwriter.Init(&strBuilder, 40, 8, 0, '\t', 0) - line := value - for i := 0; i < len(element); i++ { - line += "\t" + element[i] + var sb strings.Builder + sb.WriteString(value) + for _, e := range element { + sb.WriteString("\t") + sb.WriteString(e) } - fmt.Fprintf(tabwriter, "%s", line) //write into tab -> write into string builder + fmt.Fprintf(tabwriter, "%s", sb.String()) //write into tab -> write into string builder tabwriter.Flush() // Flush before calling String() cfg.ResultLogger.Println(strBuilder.String()) diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go new file mode 100644 index 0000000..ab3d860 --- /dev/null +++ b/pkg/mcp/server.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "bufio" + "encoding/json" + "fmt" + "os" +) + +// FuzzFn is called when the MCP client invokes the "fuzz" tool. +type FuzzFn func(args map[string]any) (string, error) + +type mcpRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type mcpResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Serve starts the MCP server over stdio. Blocks until stdin is closed. +func Serve(fuzzFn FuzzFn) { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var req mcpRequest + if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + continue + } + // Notifications have no id — no response required + if req.ID == nil { + continue + } + result, rpcErr := dispatch(req, fuzzFn) + resp := mcpResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: result, + Error: rpcErr, + } + data, _ := json.Marshal(resp) + fmt.Println(string(data)) + } +} + +func dispatch(req mcpRequest, fuzzFn FuzzFn) (any, *rpcError) { + switch req.Method { + case "initialize": + return map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": "cfuzz", "version": "2.0.0"}, + }, nil + case "tools/list": + return map[string]any{"tools": []any{fuzzToolSchema()}}, nil + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + return nil, &rpcError{Code: -32600, Message: err.Error()} + } + if p.Name != "fuzz" { + return nil, &rpcError{Code: -32601, Message: "unknown tool: " + p.Name} + } + output, err := fuzzFn(p.Arguments) + if err != nil { + return nil, &rpcError{Code: -32000, Message: err.Error()} + } + return map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": output}, + }, + }, nil + default: + return nil, &rpcError{Code: -32601, Message: "method not found: " + req.Method} + } +} + +func fuzzToolSchema() map[string]any { + return map[string]any{ + "name": "fuzz", + "description": "Fuzz a shell command by substituting wordlist entries for the FUZZ keyword and returning filtered results.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string", "description": "Shell command containing FUZZ keyword"}, + "wordlist": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Words to substitute for FUZZ"}, + "threads": map[string]any{"type": "integer", "description": "Max concurrent workers (default 50)"}, + "timeout": map[string]any{"type": "integer", "description": "Per-command timeout in seconds (default 30)"}, + "success_only": map[string]any{"type": "boolean", "description": "Only return results with exit code 0"}, + "stdout_word": map[string]any{"type": "string", "description": "Only return results where stdout contains this word"}, + "ai_filter": map[string]any{"type": "string", "description": "Natural language filter (requires ANTHROPIC_API_KEY)"}, + }, + "required": []string{"command", "wordlist"}, + }, + } +}