diff --git a/formatter/format.go b/formatter/format.go index 3e7d39d..a8bc420 100644 --- a/formatter/format.go +++ b/formatter/format.go @@ -12,13 +12,15 @@ const ( MarkdownOutput OutputFormat = "md" // JSONOutput constant defines OutputFormat for JavaScript Object Notation, which is more useful for machine-related operations (parsing) JSONOutput OutputFormat = "json" + // DotOutput constant defined OutputFormat for Dot (Graphviz), which can be used to generate various graphs + DotOutput OutputFormat = "dot" ) // IsValid checks whether requested output format is valid func (of OutputFormat) IsValid() bool { // markdown & md is essentially the same thing switch of { - case "markdown", "md", "html", "csv", "json": + case "markdown", "md", "html", "csv", "json", "dot": return true } return false @@ -35,6 +37,8 @@ func (of OutputFormat) FileOutputFormat() OutputFormat { return CSVOutput case "json": return JSONOutput + case "dot": + return DotOutput } return HTMLOutput } diff --git a/formatter/format_test.go b/formatter/format_test.go index d559147..34283d4 100644 --- a/formatter/format_test.go +++ b/formatter/format_test.go @@ -41,6 +41,11 @@ func TestOutputFormat_FileOutputFormat(t *testing.T) { of: "json", want: "json", }, + { + name: "dot", + of: "dot", + want: "dot", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -87,6 +92,11 @@ func TestOutputFormat_IsValid(t *testing.T) { of: "csv", want: true, }, + { + name: "dot", + of: "dot", + want: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/formatter/formatter.go b/formatter/formatter.go index dde9723..0c4c7a9 100644 --- a/formatter/formatter.go +++ b/formatter/formatter.go @@ -25,6 +25,10 @@ func New(config *Config) Formatter { return &CSVFormatter{ config, } + case DotOutput: + return &DotFormatter{ + config, + } } return nil } diff --git a/formatter/formatter_dot.go b/formatter/formatter_dot.go new file mode 100644 index 0000000..a503bb3 --- /dev/null +++ b/formatter/formatter_dot.go @@ -0,0 +1,111 @@ +package formatter + +import ( + _ "embed" + "fmt" + "strings" + "text/template" +) + +type DotFormatter struct { + config *Config +} + +//go:embed resources/templates/graphviz.tmpl +// DotTemplate variable is used to store contents of graphviz template +var DotTemplate string + +const ( + DotOpenPortColor = "#228B22" + DotFilteredPortColor = "#FFAE00" + DotClosedPortColor = "#DC143C" + DotDefaultColor = "gray" + + DotFontStyle = "monospace" + + DotLayout = "dot" +) + +var DotDefaultOptions = map[string]string{ + "default_font": DotFontStyle, + "layout": DotLayout, + "color_default": DotDefaultColor, +} + +type DotTemplateData struct { + NMAPRun *NMAPRun + Constants map[string]string +} + +// Format the data and output it to appropriate io.Writer +func (f *DotFormatter) Format(td *TemplateData, templateContent string) (err error) { + tmpl := template.New("dot") + f.defineTemplateFunctions(tmpl) + tmpl, err = tmpl.Parse(templateContent) + if err != nil { + return + } + dotTemplateData := DotTemplateData{ + NMAPRun: &td.NMAPRun, + Constants: DotDefaultOptions, + } + return tmpl.Execute(f.config.Writer, dotTemplateData) +} + +// defaultTemplateContent returns default template content for any typical chosen formatter (HTML or Markdown) +func (f *DotFormatter) defaultTemplateContent() string { + return DotTemplate +} + +// defineTemplateFunctions defines all template functions that are used in dot templates +func (f *DotFormatter) defineTemplateFunctions(tmpl *template.Template) { + tmpl.Funcs( + template.FuncMap{ + "clean_ip": cleanIP, + "port_state_color": portStateColor, + "hop_list": hopList, + }, + ) +} + +// cleanIP removes dots from IP address to make it possible to use in graphviz as an ID +func cleanIP(ip string) string { + return strings.ReplaceAll(ip, ".", "") +} + +// portStateColor returns hexademical color value for state port +func portStateColor(port *Port) string { + switch port.State.State { + case "open": + return DotOpenPortColor + case "filtered": + return DotFilteredPortColor + case "closed": + return DotClosedPortColor + } + return DotDefaultColor +} + +// hopList function returns a map with a list of hops where very first hop is `startHop` (scanner itself) +func hopList(hops []Hop, startHop string, endHopName string, endHopKey int) map[string]string { + var hopList map[string]string = map[string]string{} + var previous *Hop = nil + for i := range hops { + // Skip last hop, because it has the same IP as the target server + if i == len(hops)-1 { + break + } + if i == 0 { + hopList[startHop] = fmt.Sprintf("hop%s", hops[i].IPAddr) + } else { + hopList[fmt.Sprintf("hop%s", previous.IPAddr)] = fmt.Sprintf("hop%s", hops[i].IPAddr) + } + previous = &hops[i] + } + if previous != nil { + hopList[fmt.Sprintf("hop%s", previous.IPAddr)] = fmt.Sprintf("%s%d", endHopName, endHopKey) + } else { + hopList[startHop] = fmt.Sprintf("%s%d", endHopName, endHopKey) + } + return hopList +} diff --git a/formatter/formatter_dot_test.go b/formatter/formatter_dot_test.go new file mode 100644 index 0000000..7108465 --- /dev/null +++ b/formatter/formatter_dot_test.go @@ -0,0 +1,297 @@ +package formatter + +import ( + _ "embed" + "reflect" + "strings" + "testing" +) + +func Test_hopList(t *testing.T) { + type args struct { + hops []Hop + startHop string + endHopName string + endHopKey int + } + tests := []struct { + name string + args args + want map[string]string + }{ + { + name: "No hops", + args: args{ + hops: []Hop{}, + startHop: "scanner", + endHopName: "srv", + endHopKey: 0, + }, + want: map[string]string{ + "scanner": "srv0", + }, + }, + { + name: "3 Hops", + args: args{ + hops: []Hop{ + { + IPAddr: "192.168.250.1", + }, + { + IPAddr: "192.168.1.1", + }, + { + IPAddr: "10.10.10.1", + }, + }, + startHop: "scanner", + endHopName: "srv", + endHopKey: 0, + }, + want: map[string]string{ + "scanner": "hop192.168.250.1", + "hop192.168.250.1": "hop192.168.1.1", + "hop192.168.1.1": "srv0", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hopList(tt.args.hops, tt.args.startHop, tt.args.endHopName, tt.args.endHopKey); !reflect.DeepEqual(got, tt.want) { + t.Errorf("hopList() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_portStateColor(t *testing.T) { + type args struct { + port *Port + } + tests := []struct { + name string + args args + want string + }{ + { + name: "Default color", + args: args{ + port: &Port{ + State: PortState{ + State: "unknown", + }, + }, + }, + want: "gray", + }, + { + name: "Open port color", + args: args{ + port: &Port{ + State: PortState{ + State: "open", + }, + }, + }, + want: "#228B22", + }, + { + name: "Filtered port color", + args: args{ + port: &Port{ + State: PortState{ + State: "filtered", + }, + }, + }, + want: "#FFAE00", + }, + { + name: "Closed port color", + args: args{ + port: &Port{ + State: PortState{ + State: "closed", + }, + }, + }, + want: "#DC143C", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := portStateColor(tt.args.port); got != tt.want { + t.Errorf("portStateColor() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_cleanIP(t *testing.T) { + type args struct { + ip string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "Local network IP", + args: args{ + ip: "192.168.1.1", + }, + want: "19216811", + }, + { + name: "Loopback", + args: args{ + ip: "127.0.0.1", + }, + want: "127001", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cleanIP(tt.args.ip); got != tt.want { + t.Errorf("cleanIP() = %v, want %v", got, tt.want) + } + }) + } +} + +type dotMockedWriter struct { + data []byte + err error +} + +func (w *dotMockedWriter) Write(p []byte) (n int, err error) { + if w.err != nil { + return 0, w.err + } + w.data = append(w.data, p...) + return len(p), nil +} + +func (w *dotMockedWriter) Close() error { + return nil +} + +func TestDotFormatter_Format(t *testing.T) { + type fields struct { + config *Config + } + type args struct { + td *TemplateData + templateContent string + } + tests := []struct { + name string + fields fields + args args + validate func(f *DotFormatter, output string, t *testing.T) + wantErr bool + }{ + { + name: "Failed to parse template", + fields: fields{ + &Config{ + Writer: &dotMockedWriter{}, + }, + }, + args: args{ + td: &TemplateData{}, + templateContent: "wrong template content {{.nonexistent_variable}}", + }, + validate: func(f *DotFormatter, output string, t *testing.T) { + }, + wantErr: true, + }, + { + name: "1 target, 2 hops", + fields: fields{ + &Config{ + Writer: &dotMockedWriter{}, + }, + }, + args: args{ + td: &TemplateData{ + NMAPRun: NMAPRun{ + Scanner: "nmap", + Host: []Host{ + { + StartTime: 0, + EndTime: 0, + Port: []Port{}, + HostAddress: []HostAddress{ + { + Address: "10.10.10.12", + AddressType: "ipv4", + }, + }, + HostNames: HostNames{}, + Status: HostStatus{ + State: "up", + }, + OS: OS{}, + Trace: Trace{ + Port: 20, + Protocol: "tcp", + Hops: []Hop{ + { + IPAddr: "192.168.100.1", + }, + { + IPAddr: "192.168.1.1", + }, + { + IPAddr: "10.10.10.12", + }, + }, + }, + Uptime: Uptime{}, + Distance: Distance{}, + TCPSequence: TCPSequence{}, + IPIDSequence: IPIDSequence{}, + TCPTSSequence: TCPTSSequence{}, + }, + }, + }, + CustomOptions: DotDefaultOptions, + }, + templateContent: DotTemplate, + }, + wantErr: false, + validate: func(f *DotFormatter, output string, t *testing.T) { + if !strings.Contains(output, "srv0 [label=\"10.10.10.12\", tooltip=\"10.10.10.12\", shape=hexagon, style=filled];") { + t.Error("Does not contain correct sv0") + } + hops := []string{ + "hop1921681001 [label=\"\", tooltip=\"192.168.100.1\", shape=circle, height=.12, width=.12, style=filled];", + "hop19216811 [label=\"\", tooltip=\"192.168.1.1\", shape=circle, height=.12, width=.12, style=filled];", + } + for i := range hops { + if !strings.Contains(output, hops[i]) { + t.Errorf("Could not find hop: %s", hops[i]) + } + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &dotMockedWriter{} + f := &DotFormatter{ + config: &Config{ + Writer: output, + }, + } + if err := f.Format(tt.args.td, tt.args.templateContent); (err != nil) != tt.wantErr { + t.Errorf("DotFormatter.Format() error = %v, wantErr %v", err, tt.wantErr) + } else if tt.validate != nil { + tt.validate(f, string(output.data), t) + } + }) + } +} diff --git a/formatter/formatter_test.go b/formatter/formatter_test.go index e39d0d3..6bbdbe9 100644 --- a/formatter/formatter_test.go +++ b/formatter/formatter_test.go @@ -61,6 +61,15 @@ func TestNew(t *testing.T) { }, want: &CSVFormatter{config: &Config{OutputFormat: CSVOutput}}, }, + { + name: "DOT output", + args: args{ + config: &Config{ + OutputFormat: DotOutput, + }, + }, + want: &DotFormatter{config: &Config{OutputFormat: DotOutput}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/formatter/nmap_host.go b/formatter/nmap_host.go index 35e54df..9d20c39 100644 --- a/formatter/nmap_host.go +++ b/formatter/nmap_host.go @@ -1,6 +1,10 @@ package formatter -import "fmt" +import ( + "encoding/xml" + "fmt" + "strconv" +) // Host describes host related entry (`host` node) type Host struct { @@ -126,8 +130,20 @@ type Trace struct { // Hop struct contains information about HOP record with time to live, host name, IP type Hop struct { - TTL int `xml:"ttl,attr"` - IPAddr string `xml:"ipaddr,attr"` - RTT float64 `xml:"rtt,attr"` - Host string `xml:"host,attr"` + TTL int `xml:"ttl,attr"` + IPAddr string `xml:"ipaddr,attr"` + RTT RTT `xml:"rtt,attr"` + Host string `xml:"host,attr"` +} + +type RTT float64 + +// UnmarshalXMLAttr +func (r *RTT) UnmarshalXMLAttr(attr xml.Attr) error { + value, err := strconv.ParseFloat(attr.Value, 64) + if err != nil { + value = 0.0 + } + *(*float64)(r) = value + return nil } diff --git a/formatter/nmap_host_test.go b/formatter/nmap_host_test.go index 7a041e1..f10dccf 100644 --- a/formatter/nmap_host_test.go +++ b/formatter/nmap_host_test.go @@ -1,6 +1,8 @@ package formatter -import "testing" +import ( + "testing" +) func TestHost_JoinedAddresses(t *testing.T) { type fields struct { diff --git a/formatter/nmap_run.go b/formatter/nmap_run.go index 6950e94..9b55d0f 100644 --- a/formatter/nmap_run.go +++ b/formatter/nmap_run.go @@ -55,3 +55,20 @@ type StatHosts struct { Down int `xml:"down,attr"` Total int `xml:"total,attr"` } + +// AllHops is getting all possible hops that occurred during the scan and +// merges them uniquely into one map +func (n *NMAPRun) AllHops() map[string]Hop { + var hops map[string]Hop = map[string]Hop{} + for i := range n.Host { + for j := range n.Host[i].Trace.Hops { + // Skip the last hop, because it has the same IP as the target server + if j == len(n.Host[i].Trace.Hops)-1 { + break + } + hop := n.Host[i].Trace.Hops[j] + hops[hop.IPAddr] = hop + } + } + return hops +} diff --git a/formatter/resources/templates/graphviz.tmpl b/formatter/resources/templates/graphviz.tmpl new file mode 100644 index 0000000..f214fb3 --- /dev/null +++ b/formatter/resources/templates/graphviz.tmpl @@ -0,0 +1,28 @@ +strict digraph "Scan" { + fontname="{{ index .Constants "default_font" }}" + node [fontname="{{ index .Constants "default_font" }}", width=.25, height=.375, fontsize=9] + edge [fontname="{{ index .Constants "default_font" }}"] + layout={{ index .Constants "layout" }} + + scanner [label="{{ .NMAPRun.Scanner }}", shape=hexagon, style=filled]; + + {{- $hops := .NMAPRun.AllHops }} + {{ range $key, $value := $hops -}} + hop{{ clean_ip $key }} [label="", tooltip="{{ $key }}", shape=circle, height=.12, width=.12, style=filled]; + {{ end }} + + {{ range $key, $value := .NMAPRun.Host -}} + {{ if eq $value.Status.State "up" -}} + srv{{ $key }} [label="{{ $value.JoinedAddresses "/" }}", tooltip="{{ $value.JoinedAddresses "/" }}", shape=hexagon, style=filled]; + {{ range $portKey, $portValue := $value.Port }} + srv{{ $key }}_port_{{ $portValue.Protocol }}_{{ $portValue.PortID }} [label="{{ $portValue.Protocol }}/{{ $portValue.PortID }} ({{ $portValue.State.State }})", tooltip="{{ $portValue.Protocol }}/{{ $portValue.PortID }} ({{ $portValue.State.State }})", shape=underline, width=.12, height=.12, fontsize=8, color="{{ port_state_color $portValue }}"]; + srv{{ $key }} -> srv{{ $key }}_port_{{ $portValue.Protocol }}_{{ $portValue.PortID }} [arrowhead=none]; + {{ end -}} + + {{ range $hopSource, $hopTarget := hop_list $value.Trace.Hops "scanner" "srv" $key }} + {{ clean_ip $hopSource }} -> {{ clean_ip $hopTarget }} [arrowhead=none]; + {{- end }} + + {{- end }} + {{ end }} +} diff --git a/formatter/workflow_test.go b/formatter/workflow_test.go index 3054625..b396b68 100644 --- a/formatter/workflow_test.go +++ b/formatter/workflow_test.go @@ -69,6 +69,64 @@ func TestMainWorkflow_parse(t *testing.T) { `, fileName: "main_workflow_parse_4_test", }, + { + name: "XML types test for trace: hops", + w: &MainWorkflow{ + Config: &Config{}, + }, + wantNMAPRun: NMAPRun{ + Scanner: "nmap", + Version: "5.59BETA3", + ScanInfo: ScanInfo{ + Services: "1-1000", + }, + Host: []Host{ + { + HostAddress: []HostAddress{ + { + Address: "10.10.10.20", + AddressType: "ipv4", + }, + }, + Trace: Trace{ + Port: 256, + Protocol: "tcp", + Hops: []Hop{ + { + TTL: 1, + IPAddr: "192.168.200.1", + RTT: 0, + }, + { + TTL: 2, + IPAddr: "192.168.1.1", + RTT: 10.20, + }, + { + TTL: 3, + IPAddr: "10.10.10.20", + RTT: 23.30, + }, + }, + }, + }, + }, + }, + wantErr: false, + fileContent: ` + + + +
+ + + + + + + `, + fileName: "main_workflow_parse_5_test_hops", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {