feat(reporting): add PDF export option for scan results

This commit is contained in:
Gengyscan
2026-03-17 14:58:40 +01:00
parent 979c867301
commit 42b7a11f2c
9 changed files with 375 additions and 0 deletions
+1
View File
@@ -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),
)
+1
View File
@@ -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
+2
View File
@@ -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=
+12
View File
@@ -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
+234
View File
@@ -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)
}
+110
View File
@@ -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)
}
+3
View File
@@ -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"`
+9
View File
@@ -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)
+3
View File
@@ -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,