mirror of
https://github.com/projectdiscovery/nuclei
synced 2026-06-08 16:50:47 +00:00
feat(reporting): add PDF export option for scan results
This commit is contained in:
@@ -329,6 +329,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.StringVarP(&options.SarifExport, "sarif-export", "se", "", "file to export results in SARIF format"),
|
||||
flagSet.StringVarP(&options.JSONExport, "json-export", "je", "", "file to export results in JSON format"),
|
||||
flagSet.StringVarP(&options.JSONLExport, "jsonl-export", "jle", "", "file to export results in JSONL(ine) format"),
|
||||
flagSet.StringVarP(&options.PDFExport, "pdf-export", "pe", "", "file to export results in PDF format"),
|
||||
flagSet.StringSliceVarP(&options.Redact, "redact", "rd", nil, "redact given list of keys from query parameter, request header and body", goflags.CommaSeparatedStringSliceOptions),
|
||||
)
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ require (
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-pdf/fpdf v0.9.0 // indirect
|
||||
github.com/go-pg/zerochecker v0.2.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
|
||||
@@ -421,6 +421,8 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||
github.com/go-pg/pg/v10 v10.15.0 h1:6DQwbaxJz/e4wvgzbxBkBLiL/Uuk87MGgHhkURtzx24=
|
||||
github.com/go-pg/pg/v10 v10.15.0/go.mod h1:FIn/x04hahOf9ywQ1p68rXqaDVbTRLYlu4MQR0lhoB8=
|
||||
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/pdf"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/sarif"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates/extensions"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
@@ -342,6 +343,17 @@ func createReportingOptions(options *types.Options) (*reporting.Options, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.PDFExport != "" {
|
||||
if reportingOptions.PDFExporter != nil {
|
||||
reportingOptions.PDFExporter.File = options.PDFExport
|
||||
reportingOptions.PDFExporter.OmitRaw = options.OmitRawRequests
|
||||
} else {
|
||||
reportingOptions.PDFExporter = &pdf.Options{
|
||||
File: options.PDFExport,
|
||||
OmitRaw: options.OmitRawRequests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportingOptions.OmitRaw = options.OmitRawRequests
|
||||
reportingOptions.ExecutionId = options.ExecutionId
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
fpdf "github.com/go-pdf/fpdf"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFile = "nuclei-report.pdf"
|
||||
maxRawLen = 4096
|
||||
)
|
||||
|
||||
// Options contains the configuration options for PDF exporter client.
|
||||
type Options struct {
|
||||
// File is the file to export found results to in PDF format.
|
||||
File string `yaml:"file"`
|
||||
// OmitRaw omits request/response from the report.
|
||||
OmitRaw bool `yaml:"omit-raw"`
|
||||
}
|
||||
|
||||
// Exporter is an exporter for nuclei PDF output format.
|
||||
type Exporter struct {
|
||||
options *Options
|
||||
mu sync.Mutex
|
||||
results []output.ResultEvent
|
||||
}
|
||||
|
||||
// New creates a new PDF exporter integration client based on options.
|
||||
func New(options *Options) (*Exporter, error) {
|
||||
opts := &Options{}
|
||||
if options != nil {
|
||||
*opts = *options
|
||||
}
|
||||
if opts.File == "" {
|
||||
opts.File = defaultFile
|
||||
}
|
||||
return &Exporter{
|
||||
options: opts,
|
||||
results: make([]output.ResultEvent, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Export appends a result event to the report buffer.
|
||||
func (e *Exporter) Export(event *output.ResultEvent) error {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
row := *event
|
||||
if e.options.OmitRaw {
|
||||
row.Request = ""
|
||||
row.Response = ""
|
||||
}
|
||||
e.results = append(e.results, row)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close generates the PDF report and writes it to disk.
|
||||
// Returns nil without creating a file when there are no results.
|
||||
func (e *Exporter) Close() error {
|
||||
e.mu.Lock()
|
||||
snapshot := make([]output.ResultEvent, len(e.results))
|
||||
copy(snapshot, e.results)
|
||||
opts := *e.options
|
||||
e.mu.Unlock()
|
||||
|
||||
if len(snapshot) == 0 {
|
||||
return nil
|
||||
}
|
||||
if dir := filepath.Dir(opts.File); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errors.Wrap(err, "could not create directory for PDF report")
|
||||
}
|
||||
}
|
||||
return generate(&opts, snapshot)
|
||||
}
|
||||
|
||||
func generate(opts *Options, results []output.ResultEvent) error {
|
||||
doc := fpdf.New("P", "mm", "A4", "")
|
||||
doc.SetMargins(12, 15, 12)
|
||||
doc.SetAutoPageBreak(true, 18)
|
||||
renderHeader(doc)
|
||||
renderSummary(doc, results)
|
||||
renderFindings(doc, results)
|
||||
if err := doc.OutputFileAndClose(opts.File); err != nil {
|
||||
return errors.Wrap(err, "could not write PDF report")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderHeader(doc *fpdf.Fpdf) {
|
||||
doc.AddPage()
|
||||
doc.SetFont("Helvetica", "B", 18)
|
||||
doc.SetTextColor(30, 30, 30)
|
||||
doc.CellFormat(0, 10, "Nuclei Vulnerability Scan Report", "", 1, "C", false, 0, "")
|
||||
doc.SetFont("Helvetica", "", 9)
|
||||
doc.SetTextColor(100, 100, 100)
|
||||
doc.CellFormat(0, 5, "Generated: "+time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), "", 1, "C", false, 0, "")
|
||||
doc.CellFormat(0, 5, "Engine: Nuclei "+config.Version, "", 1, "C", false, 0, "")
|
||||
doc.Ln(6)
|
||||
}
|
||||
|
||||
type rgb struct{ r, g, b int }
|
||||
|
||||
var sevColors = map[string]rgb{
|
||||
"critical": {128, 0, 128},
|
||||
"high": {200, 0, 0},
|
||||
"medium": {200, 100, 0},
|
||||
"low": {170, 140, 0},
|
||||
"info": {0, 100, 180},
|
||||
"unknown": {100, 100, 100},
|
||||
}
|
||||
|
||||
var sevOrder = []string{"critical", "high", "medium", "low", "info", "unknown"}
|
||||
|
||||
func colorFor(sev string) (int, int, int) {
|
||||
if c, ok := sevColors[strings.ToLower(sev)]; ok {
|
||||
return c.r, c.g, c.b
|
||||
}
|
||||
return 100, 100, 100
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
}
|
||||
|
||||
func renderSummary(doc *fpdf.Fpdf, results []output.ResultEvent) {
|
||||
counts := make(map[string]int, len(sevOrder))
|
||||
for _, r := range results {
|
||||
sev := strings.ToLower(r.Info.SeverityHolder.Severity.String())
|
||||
if _, ok := sevColors[sev]; ok {
|
||||
counts[sev]++
|
||||
} else {
|
||||
counts["unknown"]++
|
||||
}
|
||||
}
|
||||
doc.SetFont("Helvetica", "B", 11)
|
||||
doc.SetTextColor(30, 30, 30)
|
||||
doc.CellFormat(0, 7, fmt.Sprintf("Summary - %d finding(s)", len(results)), "", 1, "", false, 0, "")
|
||||
doc.Ln(1)
|
||||
colW := 28.0
|
||||
doc.SetFont("Helvetica", "B", 9)
|
||||
for _, sev := range sevOrder {
|
||||
r, g, b := colorFor(sev)
|
||||
doc.SetFillColor(r, g, b)
|
||||
doc.SetTextColor(255, 255, 255)
|
||||
doc.CellFormat(colW, 6, capitalize(sev), "1", 0, "C", true, 0, "")
|
||||
}
|
||||
doc.Ln(-1)
|
||||
doc.SetFont("Helvetica", "", 9)
|
||||
doc.SetFillColor(245, 245, 245)
|
||||
doc.SetTextColor(30, 30, 30)
|
||||
for _, sev := range sevOrder {
|
||||
doc.CellFormat(colW, 6, fmt.Sprintf("%d", counts[sev]), "1", 0, "C", true, 0, "")
|
||||
}
|
||||
doc.Ln(10)
|
||||
}
|
||||
|
||||
func renderFindings(doc *fpdf.Fpdf, results []output.ResultEvent) {
|
||||
doc.SetFont("Helvetica", "B", 11)
|
||||
doc.SetTextColor(30, 30, 30)
|
||||
doc.CellFormat(0, 7, "Findings", "", 1, "", false, 0, "")
|
||||
doc.Ln(1)
|
||||
for i, r := range results {
|
||||
sev := strings.ToLower(r.Info.SeverityHolder.Severity.String())
|
||||
cr, cg, cb := colorFor(sev)
|
||||
doc.SetFont("Helvetica", "B", 10)
|
||||
doc.SetFillColor(cr, cg, cb)
|
||||
doc.SetTextColor(255, 255, 255)
|
||||
doc.CellFormat(0, 7, safeStr(fmt.Sprintf("[%s] %s", strings.ToUpper(sev), r.Info.Name)), "0", 1, "", true, 0, "")
|
||||
doc.SetFont("Helvetica", "", 9)
|
||||
doc.SetTextColor(30, 30, 30)
|
||||
doc.CellFormat(30, 5, "Host:", "0", 0, "", false, 0, "")
|
||||
doc.CellFormat(0, 5, safeStr(r.Host), "0", 1, "", false, 0, "")
|
||||
doc.CellFormat(30, 5, "Template:", "0", 0, "", false, 0, "")
|
||||
doc.CellFormat(0, 5, safeStr(r.TemplateID), "0", 1, "", false, 0, "")
|
||||
if r.Info.Description != "" {
|
||||
doc.SetFont("Helvetica", "I", 8)
|
||||
doc.SetTextColor(60, 60, 60)
|
||||
doc.MultiCell(0, 4, safeStr(r.Info.Description), "", "", false)
|
||||
}
|
||||
if r.Request != "" {
|
||||
renderCodeBlock(doc, "Request", r.Request)
|
||||
}
|
||||
if r.Response != "" {
|
||||
renderCodeBlock(doc, "Response", r.Response)
|
||||
}
|
||||
if i < len(results)-1 {
|
||||
doc.Ln(3)
|
||||
doc.SetDrawColor(200, 200, 200)
|
||||
doc.Line(12, doc.GetY(), 198, doc.GetY())
|
||||
doc.Ln(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderCodeBlock(doc *fpdf.Fpdf, label, content string) {
|
||||
if len(content) > maxRawLen {
|
||||
content = content[:maxRawLen] + "\n[truncated]"
|
||||
}
|
||||
doc.SetFont("Helvetica", "B", 8)
|
||||
doc.SetTextColor(60, 60, 60)
|
||||
doc.CellFormat(0, 5, label+":", "0", 1, "", false, 0, "")
|
||||
doc.SetFont("Courier", "", 7)
|
||||
doc.SetFillColor(240, 240, 240)
|
||||
doc.SetTextColor(40, 40, 40)
|
||||
doc.MultiCell(0, 4, safeStr(content), "1", "", true)
|
||||
}
|
||||
|
||||
// safeStr replaces characters outside ISO-8859-1 with '?' for fpdf compatibility.
|
||||
func safeStr(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for _, r := range s {
|
||||
if r > 255 {
|
||||
out = append(out, '?')
|
||||
} else {
|
||||
out = append(out, byte(r))
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func makeEvent(sev severity.Severity) *output.ResultEvent {
|
||||
return &output.ResultEvent{
|
||||
TemplateID: "test-template",
|
||||
Host: "http://example.com",
|
||||
Matched: "http://example.com/vulnerable",
|
||||
Type: "http",
|
||||
Timestamp: time.Now(),
|
||||
Info: model.Info{
|
||||
Name: "Test Finding",
|
||||
Authors: stringslice.StringSlice{Value: "test"},
|
||||
SeverityHolder: severity.Holder{Severity: sev},
|
||||
Description: "A test vulnerability description.",
|
||||
},
|
||||
Request: "GET / HTTP/1.1\r\nHost: example.com",
|
||||
Response: "HTTP/1.1 200 OK\r\nContent-Type: text/html",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_Defaults(t *testing.T) {
|
||||
exp, err := New(&Options{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultFile, exp.options.File)
|
||||
}
|
||||
|
||||
func TestNew_CustomFile(t *testing.T) {
|
||||
exp, err := New(&Options{File: "custom.pdf"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "custom.pdf", exp.options.File)
|
||||
}
|
||||
|
||||
func TestExport_NilEvent(t *testing.T) {
|
||||
exp, err := New(&Options{File: "test.pdf"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exp.Export(nil))
|
||||
require.Empty(t, exp.results)
|
||||
}
|
||||
|
||||
func TestClose_EmptyResults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "report.pdf")
|
||||
exp, err := New(&Options{File: out})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exp.Close())
|
||||
_, statErr := os.Stat(out)
|
||||
require.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
|
||||
func TestClose_WritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "report.pdf")
|
||||
exp, err := New(&Options{File: out})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exp.Export(makeEvent(severity.High)))
|
||||
require.NoError(t, exp.Close())
|
||||
|
||||
info, err := os.Stat(out)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, info.Size(), int64(0))
|
||||
}
|
||||
|
||||
func TestExport_OmitRaw(t *testing.T) {
|
||||
exp, err := New(&Options{File: "test.pdf", OmitRaw: true})
|
||||
require.NoError(t, err)
|
||||
event := makeEvent(severity.High)
|
||||
require.NoError(t, exp.Export(event))
|
||||
|
||||
require.Len(t, exp.results, 1)
|
||||
require.Empty(t, exp.results[0].Request)
|
||||
require.Empty(t, exp.results[0].Response)
|
||||
require.NotEmpty(t, event.Request)
|
||||
require.NotEmpty(t, event.Response)
|
||||
}
|
||||
|
||||
func TestExport_Concurrency(t *testing.T) {
|
||||
exp, err := New(&Options{File: filepath.Join(t.TempDir(), "report.pdf")})
|
||||
require.NoError(t, err)
|
||||
|
||||
const workers = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
require.NoError(t, exp.Export(makeEvent(severity.Medium)))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.Len(t, exp.results, workers)
|
||||
}
|
||||
|
||||
func TestSafeStr_ReplacesNonLatin1(t *testing.T) {
|
||||
result := safeStr("hello 世界")
|
||||
require.Equal(t, "hello ??", result)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/mongo"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/pdf"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/sarif"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/splunk"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
|
||||
@@ -48,6 +49,8 @@ type Options struct {
|
||||
JSONExporter *jsonexporter.Options `yaml:"json"`
|
||||
// JSONLExporter contains configuration options for JSONL Exporter Module
|
||||
JSONLExporter *jsonl.Options `yaml:"jsonl"`
|
||||
// PDFExporter contains configuration options for PDF Exporter Module
|
||||
PDFExporter *pdf.Options `yaml:"pdf"`
|
||||
// MongoDBExporter containers the configuration options for the MongoDB Exporter Module
|
||||
MongoDBExporter *mongo.Options `yaml:"mongodb"`
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/dedupe"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/es"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/pdf"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/sarif"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/splunk"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
|
||||
@@ -152,6 +153,13 @@ func New(options *Options, db string, doNotDedupe bool) (Client, error) {
|
||||
}
|
||||
client.exporters = append(client.exporters, exporter)
|
||||
}
|
||||
if options.PDFExporter != nil {
|
||||
exporter, err := pdf.New(options.PDFExporter)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrapf(err, "could not create export client: %v", ErrExportClientCreation)
|
||||
}
|
||||
client.exporters = append(client.exporters, exporter)
|
||||
}
|
||||
if options.ElasticsearchExporter != nil {
|
||||
options.ElasticsearchExporter.HttpClient = options.HttpClient
|
||||
options.ElasticsearchExporter.ExecutionId = options.ExecutionId
|
||||
@@ -225,6 +233,7 @@ func CreateConfigIfNotExists() error {
|
||||
SplunkExporter: &splunk.Options{},
|
||||
JSONExporter: &json_exporter.Options{},
|
||||
JSONLExporter: &jsonl.Options{},
|
||||
PDFExporter: &pdf.Options{},
|
||||
MongoDBExporter: &mongo.Options{},
|
||||
}
|
||||
reportingFile, err := os.Create(reportingConfig)
|
||||
|
||||
@@ -240,6 +240,8 @@ type Options struct {
|
||||
JSONExport string
|
||||
// JSONLExport is the file to export JSONL output format to
|
||||
JSONLExport string
|
||||
// PDFExport is the file to export PDF output format to
|
||||
PDFExport string
|
||||
// Redact redacts given keys in
|
||||
Redact goflags.StringSlice
|
||||
// EnableProgressBar enables progress bar
|
||||
@@ -575,6 +577,7 @@ func (options *Options) Copy() *Options {
|
||||
OmitTemplate: options.OmitTemplate,
|
||||
JSONExport: options.JSONExport,
|
||||
JSONLExport: options.JSONLExport,
|
||||
PDFExport: options.PDFExport,
|
||||
Redact: options.Redact,
|
||||
EnableProgressBar: options.EnableProgressBar,
|
||||
TemplateDisplay: options.TemplateDisplay,
|
||||
|
||||
Reference in New Issue
Block a user