package charts
import (
"fmt"
"os"
"sort"
"time"
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/components"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/labstack/echo/v4"
"github.com/projectdiscovery/nuclei/v3/pkg/scan/events"
sliceutil "github.com/projectdiscovery/utils/slice"
)
const (
TopK = 50
SpacerHeight = "50px"
)
func (s *ScanEventsCharts) AllCharts(c echo.Context) error {
page := s.allCharts(c)
return page.Render(c.Response().Writer)
}
func (s *ScanEventsCharts) GenerateHTML(filePath string) error {
page := s.allCharts(nil)
output, err := os.Create(filePath)
if err != nil {
return err
}
defer func() {
_ = output.Close()
}()
return page.Render(output)
}
func (s *ScanEventsCharts) allCharts(c echo.Context) *components.Page {
page := components.NewPage()
page.PageTitle = "Nuclei Charts"
line1 := s.totalRequestsOverTime(c)
kline := s.topSlowTemplates(c)
line2 := s.requestsVSInterval(c)
line3 := s.concurrencyVsTime(c)
page.AddCharts(line1, kline, line2, line3)
page.SetLayout(components.PageCenterLayout)
page.Validate()
return page
}
func (s *ScanEventsCharts) TotalRequestsOverTime(c echo.Context) error {
line := s.totalRequestsOverTime(c)
return line.Render(c.Response().Writer)
}
func (s *ScanEventsCharts) totalRequestsOverTime(c echo.Context) *charts.Line {
line := charts.NewLine()
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Nuclei: Total Requests vs Time",
Subtitle: "Chart Shows Total Requests Count Over Time (for each/all Protocols)",
}),
)
startTime := time.Now()
var endTime time.Time
for _, event := range s.data {
if event.Time.Before(startTime) {
startTime = event.Time
}
if event.Time.After(endTime) {
endTime = event.Time
}
}
data := getCategoryRequestCount(s.data)
max := 0
for _, v := range data {
if len(v) > max {
max = len(v)
}
}
line.SetXAxis(time.Now().Format(time.RFC3339))
for k, v := range data {
lineData := make([]opts.LineData, 0)
temp := 0
for _, scanEvent := range v {
temp += scanEvent.MaxRequests
val := scanEvent.Time.Sub(startTime)
lineData = append(lineData, opts.LineData{
Value: []interface{}{val.Milliseconds(), temp},
Name: scanEvent.TemplateID,
})
}
line.AddSeries(k, lineData, charts.WithLineChartOpts(opts.LineChart{Smooth: opts.Bool(false)}), charts.WithLabelOpts(opts.Label{Show: opts.Bool(true), Position: "top"}))
}
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{Title: "Nuclei: total-req vs time"}),
charts.WithXAxisOpts(opts.XAxis{Name: "Time", Type: "time", AxisLabel: &opts.AxisLabel{Show: opts.Bool(true), ShowMaxLabel: opts.Bool(true), Formatter: opts.FuncOpts(`function (date) { return (date/1000)+'s'; }`)}}),
charts.WithYAxisOpts(opts.YAxis{Name: "Requests Sent", Type: "value"}),
charts.WithInitializationOpts(opts.Initialization{Theme: "dark"}),
charts.WithDataZoomOpts(opts.DataZoom{Type: "slider", Start: 0, End: 100}),
charts.WithGridOpts(opts.Grid{Left: "10%", Right: "10%", Bottom: "15%", Top: "20%"}),
charts.WithToolboxOpts(opts.Toolbox{Show: opts.Bool(true), Feature: &opts.ToolBoxFeature{
SaveAsImage: &opts.ToolBoxFeatureSaveAsImage{Show: opts.Bool(true), Name: "save", Title: "save"},
DataZoom: &opts.ToolBoxFeatureDataZoom{Show: opts.Bool(true), Title: map[string]string{"zoom": "zoom", "back": "back"}},
DataView: &opts.ToolBoxFeatureDataView{Show: opts.Bool(true), Title: "raw", Lang: []string{"raw", "exit", "refresh"}},
}}),
)
line.Validate()
return line
}
func (s *ScanEventsCharts) TopSlowTemplates(c echo.Context) error {
kline := s.topSlowTemplates(c)
return kline.Render(c.Response().Writer)
}
func (s *ScanEventsCharts) topSlowTemplates(c echo.Context) *charts.Kline {
kline := charts.NewKLine()
kline.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Nuclei: Top Slow Templates",
Subtitle: fmt.Sprintf("Chart Shows Top Slow Templates (by time taken) (Top %v)", TopK),
}),
)
ids := map[string][]int64{}
startTime := time.Now()
for _, event := range s.data {
if event.Time.Before(startTime) {
startTime = event.Time
}
}
for _, event := range s.data {
ids[event.TemplateID] = append(ids[event.TemplateID], event.Time.Sub(startTime).Milliseconds())
}
type entry struct {
ID string
KlineData opts.KlineData
start int64
end int64
}
data := []entry{}
for a, b := range ids {
if len(b) < 2 {
continue
}
d := entry{
ID: a,
KlineData: opts.KlineData{Value: []int64{b[0], b[len(b)-1], b[0], b[len(b)-1]}},
start: b[0],
end: b[len(b)-1],
}
data = append(data, d)
}
sort.Slice(data, func(i, j int) bool {
return data[i].end-data[i].start > data[j].end-data[j].start
})
x := make([]string, 0)
y := make([]opts.KlineData, 0)
for _, event := range data[:TopK] {
x = append(x, event.ID)
y = append(y, event.KlineData)
}
kline.SetXAxis(x).AddSeries("templates", y)
kline.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{Title: fmt.Sprintf("Nuclei: Top %v Slow Templates", TopK)}),
charts.WithXAxisOpts(opts.XAxis{
Type: "category",
Show: opts.Bool(true),
AxisLabel: &opts.AxisLabel{Rotate: 90, Show: opts.Bool(true), ShowMinLabel: opts.Bool(true), ShowMaxLabel: opts.Bool(true), Formatter: opts.FuncOpts(`function (value) { return value; }`)},
}),
charts.WithYAxisOpts(opts.YAxis{
Scale: opts.Bool(true),
Type: "value",
Show: opts.Bool(true),
AxisLabel: &opts.AxisLabel{Show: opts.Bool(true), Formatter: opts.FuncOpts(`function (ms) { return Math.floor(ms/60000) + 'm' + Math.floor((ms/60000 - Math.floor(ms/60000))*60) + 's'; }`)},
}),
charts.WithDataZoomOpts(opts.DataZoom{Type: "slider", Start: 0, End: 100}),
charts.WithGridOpts(opts.Grid{Left: "10%", Right: "10%", Bottom: "40%", Top: "10%"}),
charts.WithTooltipOpts(opts.Tooltip{Show: opts.Bool(true), Trigger: "item", TriggerOn: "mousemove|click", Enterable: opts.Bool(true), Formatter: opts.FuncOpts(`function (params) { return params.name ; }`)}),
charts.WithToolboxOpts(opts.Toolbox{Show: opts.Bool(true), Feature: &opts.ToolBoxFeature{
SaveAsImage: &opts.ToolBoxFeatureSaveAsImage{Show: opts.Bool(true), Name: "save", Title: "save"},
DataZoom: &opts.ToolBoxFeatureDataZoom{Show: opts.Bool(true), Title: map[string]string{"zoom": "zoom", "back": "back"}},
DataView: &opts.ToolBoxFeatureDataView{Show: opts.Bool(true), Title: "raw", Lang: []string{"raw", "exit", "refresh"}},
}}),
)
return kline
}
func (s *ScanEventsCharts) RequestsVSInterval(c echo.Context) error {
line := s.requestsVSInterval(c)
return line.Render(c.Response().Writer)
}
func (s *ScanEventsCharts) requestsVSInterval(c echo.Context) *charts.Line {
line := charts.NewLine()
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Nuclei: Requests Per Second vs Time",
Subtitle: "Chart Shows RPS (Requests Per Second) Over Time",
}),
)
sort.Slice(s.data, func(i, j int) bool {
return s.data[i].Time.Before(s.data[j].Time)
})
var interval time.Duration
if c != nil {
interval, _ = time.ParseDuration(c.QueryParam("interval"))
}
if interval <= 3 {
interval = 5 * time.Second
}
data := []opts.LineData{}
temp := 0
if len(s.data) > 0 {
orig := s.data[0].Time
startTime := orig
xaxisData := []int64{}
for _, v := range s.data {
if v.Time.Sub(startTime) > interval {
millisec := v.Time.Sub(orig).Milliseconds()
xaxisData = append(xaxisData, millisec)
data = append(data, opts.LineData{Value: temp, Name: v.Time.Sub(orig).String()})
temp = 0
startTime = v.Time
}
temp += 1
}
if temp > 0 {
millisec := s.data[len(s.data)-1].Time.Sub(orig).Milliseconds()
xaxisData = append(xaxisData, millisec)
data = append(data, opts.LineData{Value: temp, Name: s.data[len(s.data)-1].Time.Sub(orig).String()})
}
line.SetXAxis(xaxisData)
line.AddSeries("RPS", data, charts.WithLineChartOpts(opts.LineChart{Smooth: opts.Bool(false)}), charts.WithLabelOpts(opts.Label{Show: opts.Bool(true), Position: "top"}))
}
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{Title: "Nuclei: Template Execution", Subtitle: "Time Interval: " + interval.String()}),
charts.WithXAxisOpts(opts.XAxis{Name: "Time Intervals", Type: "category", AxisLabel: &opts.AxisLabel{Show: opts.Bool(true), ShowMaxLabel: opts.Bool(true), Formatter: opts.FuncOpts(`function (date) { return (date/1000)+'s'; }`)}}),
charts.WithYAxisOpts(opts.YAxis{Name: "RPS Value", Type: "value", Show: opts.Bool(true)}),
charts.WithInitializationOpts(opts.Initialization{Theme: "dark"}),
charts.WithDataZoomOpts(opts.DataZoom{Type: "slider", Start: 0, End: 100}),
charts.WithGridOpts(opts.Grid{Left: "10%", Right: "10%", Bottom: "15%", Top: "20%"}),
charts.WithToolboxOpts(opts.Toolbox{Show: opts.Bool(true), Feature: &opts.ToolBoxFeature{
SaveAsImage: &opts.ToolBoxFeatureSaveAsImage{Show: opts.Bool(true), Name: "save", Title: "save"},
DataZoom: &opts.ToolBoxFeatureDataZoom{Show: opts.Bool(true), Title: map[string]string{"zoom": "zoom", "back": "back"}},
DataView: &opts.ToolBoxFeatureDataView{Show: opts.Bool(true), Title: "raw", Lang: []string{"raw", "exit", "refresh"}},
}}),
)
line.Validate()
return line
}
func (s *ScanEventsCharts) ConcurrencyVsTime(c echo.Context) error {
line := s.concurrencyVsTime(c)
return line.Render(c.Response().Writer)
}
func (s *ScanEventsCharts) concurrencyVsTime(c echo.Context) *charts.Line {
line := charts.NewLine()
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Nuclei: Concurrency vs Time",
Subtitle: "Chart Shows Concurrency (Total Workers) Over Time",
}),
)
dataset := sliceutil.Clone(s.data)
sort.Slice(dataset, func(i, j int) bool {
return dataset[i].Time.Before(dataset[j].Time)
})
var interval time.Duration
if c != nil {
interval, _ = time.ParseDuration(c.QueryParam("interval"))
}
if interval <= 3 {
interval = 5 * time.Second
}
type entry struct {
Time time.Duration
poolsize int
}
allEntries := []entry{}
dataIndex := 0
maxIndex := len(dataset) - 1
currEntry := entry{}
lastTime := dataset[0].Time
for dataIndex <= maxIndex {
currTime := dataset[dataIndex].Time
if currTime.Sub(lastTime) > interval {
currEntry.Time = interval
allEntries = append(allEntries, currEntry)
lastTime = dataset[dataIndex-1].Time
}
if dataset[dataIndex].EventType == events.ScanStarted {
currEntry.poolsize += 1
} else {
currEntry.poolsize -= 1
}
dataIndex += 1
}
plotData := []opts.LineData{}
xaxisData := []int64{}
tempTime := time.Duration(0)
for _, v := range allEntries {
tempTime += v.Time
plotData = append(plotData, opts.LineData{Value: v.poolsize, Name: tempTime.String()})
xaxisData = append(xaxisData, tempTime.Milliseconds())
}
line.SetXAxis(xaxisData)
line.AddSeries("Concurrency", plotData, charts.WithLineChartOpts(opts.LineChart{Smooth: opts.Bool(false)}), charts.WithLabelOpts(opts.Label{Show: opts.Bool(true), Position: "top"}))
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{Title: "Nuclei: WorkerPool", Subtitle: "Time Interval: " + interval.String()}),
charts.WithXAxisOpts(opts.XAxis{Name: "Time Intervals", Type: "category", AxisLabel: &opts.AxisLabel{Show: opts.Bool(true), ShowMaxLabel: opts.Bool(true), Formatter: opts.FuncOpts(`function (date) { return (date/1000)+'s'; }`)}}),
charts.WithYAxisOpts(opts.YAxis{Name: "Total Workers", Type: "value", Show: opts.Bool(true)}),
charts.WithInitializationOpts(opts.Initialization{Theme: "dark"}),
charts.WithDataZoomOpts(opts.DataZoom{Type: "slider", Start: 0, End: 100}),
charts.WithGridOpts(opts.Grid{Left: "10%", Right: "10%", Bottom: "15%", Top: "20%"}),
charts.WithToolboxOpts(opts.Toolbox{Show: opts.Bool(true), Feature: &opts.ToolBoxFeature{
SaveAsImage: &opts.ToolBoxFeatureSaveAsImage{Show: opts.Bool(true), Name: "save", Title: "save"},
DataZoom: &opts.ToolBoxFeatureDataZoom{Show: opts.Bool(true), Title: map[string]string{"zoom": "zoom", "back": "back"}},
DataView: &opts.ToolBoxFeatureDataView{Show: opts.Bool(true), Title: "raw", Lang: []string{"raw", "exit", "refresh"}},
}}),
)
line.Validate()
return line
}
func getCategoryRequestCount(values []events.ScanEvent) map[string][]events.ScanEvent {
mx := make(map[string][]events.ScanEvent)
for _, event := range values {
mx[event.TemplateType] = append(mx[event.TemplateType], event)
}
return mx
}