diff --git a/README.md b/README.md index 833f57c..503ad08 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ The number of worker routines to use to scan ports in parallel. Default is *1000 Only show output for hosts that are confirmed as up. +### `-j` `--json` + +Output results in JSON format instead of the default human-readable format. This is useful for programmatic processing of scan results. + ### `--version` Output version information and exit. @@ -105,6 +109,33 @@ furious -s connect 192.168.1.1 furious -s device 192.168.1.1/24 -u ``` +### Output results in JSON format + +``` +furious -s connect -j 192.168.1.1 +``` + +Example JSON output: +```json +[ + { + "host": "192.168.1.1", + "status": "up", + "latency_ms": 2.5, + "open_ports": [ + { + "port": 22, + "service": "ssh" + }, + { + "port": 80, + "service": "http" + } + ] + } +] +``` + ## Troubleshooting ### `sudo: furious: command not found` diff --git a/cmd/root.go b/cmd/root.go index 0ca03c1..9a5cad0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "os/signal" @@ -22,6 +23,7 @@ var portSelection string var scanType = "stealth" var hideUnavailableHosts bool var versionRequested bool +var jsonOutput bool func init() { rootCmd.PersistentFlags().BoolVarP(&hideUnavailableHosts, "up-only", "u", hideUnavailableHosts, "Omit output for hosts which are not up") @@ -31,22 +33,29 @@ func init() { rootCmd.PersistentFlags().IntVarP(&timeoutMS, "timeout-ms", "t", timeoutMS, "Scan timeout in MS") rootCmd.PersistentFlags().IntVarP(¶llelism, "workers", "w", parallelism, "Parallel routines to scan on") rootCmd.PersistentFlags().StringVarP(&portSelection, "ports", "p", portSelection, "Port to scan. Comma separated, can sue hyphens e.g. 22,80,443,8080-8090") + rootCmd.PersistentFlags().BoolVarP(&jsonOutput, "json", "j", jsonOutput, "Output results in JSON format") } -func createScanner(ti *scan.TargetIterator, scanTypeStr string, timeout time.Duration, routines int) (scan.Scanner, error) { +func createScanner(ti *scan.TargetIterator, scanTypeStr string, timeout time.Duration, routines int, jsonOutput bool) (scan.Scanner, error) { + var scanner scan.Scanner + var err error + switch strings.ToLower(scanTypeStr) { case "stealth", "syn", "fast": if os.Geteuid() > 0 { return nil, fmt.Errorf("Access Denied: You must be a priviliged user to run this type of scan.") } - return scan.NewSynScanner(ti, timeout, routines), nil + scanner = scan.NewSynScanner(ti, timeout, routines) case "connect": - return scan.NewConnectScanner(ti, timeout, routines), nil + scanner = scan.NewConnectScanner(ti, timeout, routines) case "device": - return scan.NewDeviceScanner(ti, timeout), nil + scanner = scan.NewDeviceScanner(ti, timeout) + default: + return nil, fmt.Errorf("Unknown scan type '%s'", scanTypeStr) } - return nil, fmt.Errorf("Unknown scan type '%s'", scanTypeStr) + scanner.SetJSONOutput(jsonOutput) + return scanner, err } var rootCmd = &cobra.Command{ @@ -90,14 +99,19 @@ var rootCmd = &cobra.Command{ }() startTime := time.Now() - fmt.Printf("\nStarting scan at %s\n\n", startTime.String()) + + if !jsonOutput { + fmt.Printf("\nStarting scan at %s\n\n", startTime.String()) + } + + allResults := []scan.Result{} for _, target := range args { targetIterator := scan.NewTargetIterator(target) // creating scanner - scanner, err := createScanner(targetIterator, scanType, time.Millisecond*time.Duration(timeoutMS), parallelism) + scanner, err := createScanner(targetIterator, scanType, time.Millisecond*time.Duration(timeoutMS), parallelism, jsonOutput) if err != nil { fmt.Println(err) os.Exit(1) @@ -119,13 +133,21 @@ var rootCmd = &cobra.Command{ for _, result := range results { if !hideUnavailableHosts || result.IsHostUp() { - scanner.OutputResult(result) + if jsonOutput { + allResults = append(allResults, result) + } else { + scanner.OutputResult(result) + } } } } - fmt.Printf("Scan complete in %s.\n", time.Since(startTime).String()) + if jsonOutput { + outputJSONResults(allResults) + } else { + fmt.Printf("Scan complete in %s.\n", time.Since(startTime).String()) + } }, } @@ -179,3 +201,22 @@ func getPorts(selection string) ([]int, error) { } return ports, nil } + +func outputJSONResults(results []scan.Result) { + jsonResults := make([]interface{}, 0, len(results)) + + for _, result := range results { + if jsonData, err := result.ToJSON(); err == nil { + var jsonObj interface{} + if err := json.Unmarshal(jsonData, &jsonObj); err == nil { + jsonResults = append(jsonResults, jsonObj) + } + } + } + + if output, err := json.MarshalIndent(jsonResults, "", " "); err == nil { + fmt.Println(string(output)) + } else { + fmt.Printf("Error marshaling results to JSON: %v\n", err) + } +} diff --git a/scan/result.go b/scan/result.go index e05b1fd..94f7903 100644 --- a/scan/result.go +++ b/scan/result.go @@ -1,20 +1,21 @@ package scan import ( + "encoding/json" "fmt" "net" "time" ) type Result struct { - Host net.IP - Open []int - Closed []int - Filtered []int - Manufacturer string - MAC string - Latency time.Duration - Name string + Host net.IP `json:"host"` + Open []int `json:"open_ports"` + Closed []int `json:"closed_ports"` + Filtered []int `json:"filtered_ports"` + Manufacturer string `json:"manufacturer,omitempty"` + MAC string `json:"mac,omitempty"` + Latency time.Duration `json:"latency_ns"` + Name string `json:"hostname,omitempty"` } func NewResult(host net.IP) Result { @@ -70,3 +71,57 @@ func pad(input string, length int) string { } return input } + +// JSONResult represents a result in JSON-friendly format +type JSONResult struct { + Host string `json:"host"` + Status string `json:"status"` + Open []PortInfo `json:"open_ports"` + Closed []int `json:"closed_ports,omitempty"` + Filtered []int `json:"filtered_ports,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + MAC string `json:"mac,omitempty"` + LatencyMs float64 `json:"latency_ms"` + Hostname string `json:"hostname,omitempty"` +} + +// PortInfo represents port information for JSON output +type PortInfo struct { + Port int `json:"port"` + Service string `json:"service"` +} + +// ToJSON converts Result to JSON format +func (r Result) ToJSON() ([]byte, error) { + status := "down" + if r.IsHostUp() { + status = "up" + } + + openPorts := make([]PortInfo, len(r.Open)) + for i, port := range r.Open { + openPorts[i] = PortInfo{ + Port: port, + Service: DescribePort(port), + } + } + + latencyMs := float64(0) + if r.Latency > 0 { + latencyMs = float64(r.Latency.Nanoseconds()) / 1000000.0 + } + + jsonResult := JSONResult{ + Host: r.Host.String(), + Status: status, + Open: openPorts, + Closed: r.Closed, + Filtered: r.Filtered, + Manufacturer: r.Manufacturer, + MAC: r.MAC, + LatencyMs: latencyMs, + Hostname: r.Name, + } + + return json.Marshal(jsonResult) +} diff --git a/scan/result_test.go b/scan/result_test.go new file mode 100644 index 0000000..6f5fdfd --- /dev/null +++ b/scan/result_test.go @@ -0,0 +1,76 @@ +package scan + +import ( + "encoding/json" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResultToJSON(t *testing.T) { + // Create a test result + result := Result{ + Host: net.ParseIP("192.168.1.1"), + Open: []int{22, 80, 443}, + Closed: []int{21, 23}, + Filtered: []int{8080}, + Latency: time.Millisecond * 50, + MAC: "00:11:22:33:44:55", + Name: "test-host", + } + + // Convert to JSON + jsonData, err := result.ToJSON() + require.NoError(t, err) + + // Parse the JSON to verify structure + var jsonResult JSONResult + err = json.Unmarshal(jsonData, &jsonResult) + require.NoError(t, err) + + // Verify the JSON structure + assert.Equal(t, "192.168.1.1", jsonResult.Host) + assert.Equal(t, "up", jsonResult.Status) + assert.Equal(t, float64(50), jsonResult.LatencyMs) + assert.Equal(t, "00:11:22:33:44:55", jsonResult.MAC) + assert.Equal(t, "test-host", jsonResult.Hostname) + + // Verify open ports + require.Len(t, jsonResult.Open, 3) + assert.Equal(t, 22, jsonResult.Open[0].Port) + assert.Equal(t, "ssh", jsonResult.Open[0].Service) + assert.Equal(t, 80, jsonResult.Open[1].Port) + assert.Equal(t, "http", jsonResult.Open[1].Service) + assert.Equal(t, 443, jsonResult.Open[2].Port) + assert.Equal(t, "https", jsonResult.Open[2].Service) + + // Verify closed and filtered ports + assert.Equal(t, []int{21, 23}, jsonResult.Closed) + assert.Equal(t, []int{8080}, jsonResult.Filtered) +} + +func TestResultToJSONDownHost(t *testing.T) { + // Create a test result for a down host + result := Result{ + Host: net.ParseIP("192.168.1.100"), + Latency: -1, // Indicates host is down + } + + // Convert to JSON + jsonData, err := result.ToJSON() + require.NoError(t, err) + + // Parse the JSON to verify structure + var jsonResult JSONResult + err = json.Unmarshal(jsonData, &jsonResult) + require.NoError(t, err) + + // Verify the JSON structure for down host + assert.Equal(t, "192.168.1.100", jsonResult.Host) + assert.Equal(t, "down", jsonResult.Status) + assert.Equal(t, float64(0), jsonResult.LatencyMs) + assert.Empty(t, jsonResult.Open) +} \ No newline at end of file diff --git a/scan/scan-connect.go b/scan/scan-connect.go index ba4ddbb..19e9205 100644 --- a/scan/scan-connect.go +++ b/scan/scan-connect.go @@ -15,6 +15,7 @@ type ConnectScanner struct { maxRoutines int jobChan chan portJob ti *TargetIterator + jsonOutput bool } func NewConnectScanner(ti *TargetIterator, timeout time.Duration, paralellism int) *ConnectScanner { @@ -195,5 +196,17 @@ func (s *ConnectScanner) scanPort(target net.IP, port int) (PortState, error) { } func (s *ConnectScanner) OutputResult(result Result) { - fmt.Println(result.String()) + if s.jsonOutput { + if jsonData, err := result.ToJSON(); err == nil { + fmt.Println(string(jsonData)) + } else { + fmt.Printf("Error marshaling result to JSON: %v\n", err) + } + } else { + fmt.Println(result.String()) + } +} + +func (s *ConnectScanner) SetJSONOutput(enabled bool) { + s.jsonOutput = enabled } diff --git a/scan/scan-device.go b/scan/scan-device.go index 1e6df7a..c0965ab 100644 --- a/scan/scan-device.go +++ b/scan/scan-device.go @@ -14,8 +14,9 @@ import ( ) type DeviceScanner struct { - timeout time.Duration - ti *TargetIterator + timeout time.Duration + ti *TargetIterator + jsonOutput bool } func NewDeviceScanner(ti *TargetIterator, timeout time.Duration) *DeviceScanner { @@ -132,52 +133,63 @@ func (s *DeviceScanner) Scan(ctx context.Context, ports []int) ([]Result, error) } func (s *DeviceScanner) OutputResult(result Result) { + if s.jsonOutput { + if jsonData, err := result.ToJSON(); err == nil { + fmt.Println(string(jsonData)) + } else { + fmt.Printf("Error marshaling result to JSON: %v\n", err) + } + } else { + fmt.Printf("Scan results for host %s\n", result.Host.String()) - fmt.Printf("Scan results for host %s\n", result.Host.String()) - - status := "DOWN" - - if result.IsHostUp() { - status = "UP" - } + status := "DOWN" - fmt.Printf( - "\t%s %s\n", - pad("Status:", 24), - status, - ) + if result.IsHostUp() { + status = "UP" + } - if result.IsHostUp() { fmt.Printf( "\t%s %s\n", - pad("Latency:", 24), - result.Latency.String(), + pad("Status:", 24), + status, ) - } - if result.MAC != "" { - fmt.Printf( - "\t%s %s\n", - pad("MAC:", 24), - result.MAC, - ) - } + if result.IsHostUp() { + fmt.Printf( + "\t%s %s\n", + pad("Latency:", 24), + result.Latency.String(), + ) + } - if result.Manufacturer != "" { - fmt.Printf( - "\t%s %s\n", - pad("Manufacturer:", 24), - result.Manufacturer, - ) - } + if result.MAC != "" { + fmt.Printf( + "\t%s %s\n", + pad("MAC:", 24), + result.MAC, + ) + } - if result.Name != "" { - fmt.Printf( - "\t%s %s\n", - pad("Name:", 24), - result.Name, - ) + if result.Manufacturer != "" { + fmt.Printf( + "\t%s %s\n", + pad("Manufacturer:", 24), + result.Manufacturer, + ) + } + + if result.Name != "" { + fmt.Printf( + "\t%s %s\n", + pad("Name:", 24), + result.Name, + ) + } + + fmt.Println("") } +} - fmt.Println("") +func (s *DeviceScanner) SetJSONOutput(enabled bool) { + s.jsonOutput = enabled } diff --git a/scan/scan-syn.go b/scan/scan-syn.go index 96a18c3..65c42d1 100644 --- a/scan/scan-syn.go +++ b/scan/scan-syn.go @@ -42,6 +42,7 @@ type SynScanner struct { jobChan chan hostJob ti *TargetIterator serializeOptions gopacket.SerializeOptions + jsonOutput bool } func NewSynScanner(ti *TargetIterator, timeout time.Duration, paralellism int) *SynScanner { @@ -409,5 +410,17 @@ func (s *SynScanner) scanHost(job hostJob) (Result, error) { } func (s *SynScanner) OutputResult(result Result) { - fmt.Println(result.String()) + if s.jsonOutput { + if jsonData, err := result.ToJSON(); err == nil { + fmt.Println(string(jsonData)) + } else { + fmt.Printf("Error marshaling result to JSON: %v\n", err) + } + } else { + fmt.Println(result.String()) + } +} + +func (s *SynScanner) SetJSONOutput(enabled bool) { + s.jsonOutput = enabled } diff --git a/scan/scanner.go b/scan/scanner.go index 7193e16..3d9091e 100644 --- a/scan/scanner.go +++ b/scan/scanner.go @@ -7,4 +7,5 @@ type Scanner interface { Start() error Scan(ctx context.Context, ports []int) ([]Result, error) OutputResult(result Result) + SetJSONOutput(enabled bool) }