diff --git a/.gitignore b/.gitignore index 9605401..cebe73a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Local *.html +*.pdf gs dist/ coverage.out diff --git a/go.mod b/go.mod index a4be3e0..8e69a7e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.4 require ( github.com/GoScouter/sdk v0.1.0 + github.com/go-pdf/fpdf v0.9.0 github.com/google/go-github v17.0.0+incompatible golang.org/x/term v0.45.0 gopkg.in/src-d/go-git.v4 v4.13.1 diff --git a/go.sum b/go.sum index 5fb29d0..a6b1b5e 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +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/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -41,8 +43,9 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= diff --git a/internal/module/scan.go b/internal/module/scan.go index d712126..dfe2ec2 100644 --- a/internal/module/scan.go +++ b/internal/module/scan.go @@ -52,7 +52,7 @@ func (m *ScanModule) Name() string { } func (m *ScanModule) Description() string { - return "Crawl the target and its subdomains, then render a spider-web graph of DNS/HTTP findings to an HTML page." + return "Crawl the target and its subdomains, then render a spider-web graph of DNS/HTTP findings to an HTML page and a PDF summary." } func (m *ScanModule) Version() string { @@ -62,6 +62,7 @@ func (m *ScanModule) Version() string { type scanResult struct { summary scan.Summary path string + pdfPath string } func (r scanResult) Render() string { @@ -71,6 +72,7 @@ func (r scanResult) Render() string { fmt.Fprintf(&b, " Subdomains : %d discovered\r\n", r.summary.Subdomains) fmt.Fprintf(&b, " Reachable : %d\r\n", r.summary.Reachable) fmt.Fprintf(&b, " Graph : %s\r\n", r.path) + fmt.Fprintf(&b, " PDF summary : %s\r\n", r.pdfPath) b.WriteString("\r\n") return b.String() } @@ -79,6 +81,7 @@ func (m *ScanModule) Scout(target string, args []string) (sdk.Result, error) { fs := flag.NewFlagSet("scan", flag.ContinueOnError) fs.SetOutput(io.Discard) out := fs.String("out", "", "path for the generated HTML graph (default gs-scan-.html)") + pdfOut := fs.String("pdf-out", "", "path for the generated PDF summary (default gs-scan-.pdf)") if err := fs.Parse(args); err != nil { return nil, err } @@ -101,5 +104,18 @@ func (m *ScanModule) Scout(target string, args []string) (sdk.Result, error) { return nil, fmt.Errorf("writing graph to %s: %w", path, err) } - return scanResult{summary: summary, path: path}, nil + pdf, _, err := graph.PDF() + if err != nil { + return nil, fmt.Errorf("generating PDF summary: %w", err) + } + pdfPath := *pdfOut + if pdfPath == "" { + pdfPath = fmt.Sprintf("gs-scan-%s.pdf", summary.Target) + } + + if err := os.WriteFile(pdfPath, pdf, 0o644); err != nil { + return nil, fmt.Errorf("writing PDF summary to %s: %w", pdfPath, err) + } + + return scanResult{summary: summary, path: path, pdfPath: pdfPath}, nil } diff --git a/internal/scan/pdf.go b/internal/scan/pdf.go new file mode 100644 index 0000000..4d6e019 --- /dev/null +++ b/internal/scan/pdf.go @@ -0,0 +1,168 @@ +package scan + +import ( + "bytes" + "fmt" + "time" + + "github.com/go-pdf/fpdf" +) + +const ( + pdfMaxOutputChars = 600 +) + +func (g *Graph) PDF() ([]byte, Summary, error) { + sum := Summary{} + + pdf := fpdf.New("P", "mm", "A4", "") + pdf.SetTitle("GoScouter scan summary", false) + pdf.SetMargins(15, 15, 15) + pdf.SetAutoPageBreak(true, 20) + + if g.Root == nil { + pdf.AddPage() + pdf.SetFont("Helvetica", "B", 16) + pdf.CellFormat(0, 10, "GoScouter scan summary", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + pdf.CellFormat(0, 8, "No scan data available.", "", 1, "L", false, 0, "") + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, sum, err + } + return buf.Bytes(), sum, nil + } + + sum.Target = g.Root.Report.Host + rootReachable := g.Root.Report.Reachable() + + for _, c := range g.Root.Children { + sum.Subdomains++ + if c.Report.Reachable() { + sum.Reachable++ + } + } + + hostLink := make(map[string]int, len(g.Root.Children)+1) + hostLink[g.Root.Report.Host] = pdf.AddLink() + for _, c := range g.Root.Children { + hostLink[c.Report.Host] = pdf.AddLink() + } + + pdf.AddPage() + pdf.SetFont("Helvetica", "B", 18) + pdf.CellFormat(0, 10, "GoScouter scan summary", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 10) + pdf.SetTextColor(110, 118, 129) + pdf.CellFormat(0, 6, fmt.Sprintf("Generated %s", time.Now().Format("2006-01-02 15:04:05 MST")), "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + pdf.Ln(4) + + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Overview", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + pdf.CellFormat(0, 7, fmt.Sprintf("Target: %s", sum.Target), "", 1, "L", false, 0, "") + pdf.CellFormat(0, 7, fmt.Sprintf("Subdomains discovered: %d", sum.Subdomains), "", 1, "L", false, 0, "") + pdf.CellFormat(0, 7, fmt.Sprintf("Reachable hosts: %d / %d", sum.Reachable, sum.Subdomains), "", 1, "L", false, 0, "") + pdf.Ln(6) + + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Table of contents", "", 1, "L", false, 0, "") + pdf.Ln(1) + + tocEntry := func(host string, reachable bool, root bool) { + label := host + if root { + label += " (root)" + } + pdf.SetFont("Helvetica", "", 11) + pdf.SetTextColor(88, 166, 255) + pdf.WriteLinkID(7, label, hostLink[host]) + pdf.SetTextColor(0, 0, 0) + + pdf.SetFont("Helvetica", "", 9) + if reachable { + pdf.SetTextColor(63, 185, 80) + pdf.Write(7, " reachable") + } else { + pdf.SetTextColor(248, 81, 73) + pdf.Write(7, " no response") + } + pdf.SetTextColor(0, 0, 0) + pdf.Ln(7) + } + + tocEntry(g.Root.Report.Host, rootReachable, true) + for _, c := range g.Root.Children { + tocEntry(c.Report.Host, c.Report.Reachable(), false) + } + + pdf.AddPage() + pdf.SetFont("Helvetica", "B", 12) + pdf.CellFormat(0, 8, "Hosts", "", 1, "L", false, 0, "") + pdf.Ln(1) + + writeHost := func(host string, reachable bool, results []ModuleResult, root bool) { + // anchor this position as the destination for the host's TOC link + pdf.SetLink(hostLink[host], -1, -1) + label := host + if root { + label += " (root)" + } + pdf.Bookmark(label, 0, -1) + + pdf.SetFont("Helvetica", "B", 13) + pdf.CellFormat(140, 8, label, "", 0, "L", false, 0, "") + + pdf.SetFont("Helvetica", "B", 10) + if reachable { + pdf.SetTextColor(63, 185, 80) + pdf.CellFormat(0, 8, "reachable", "", 1, "L", false, 0, "") + } else { + pdf.SetTextColor(248, 81, 73) + pdf.CellFormat(0, 8, "no response", "", 1, "L", false, 0, "") + } + pdf.SetTextColor(0, 0, 0) + + if len(results) == 0 { + pdf.SetFont("Helvetica", "I", 9) + pdf.SetTextColor(110, 118, 129) + pdf.CellFormat(0, 6, "no module output", "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + } + for _, m := range results { + pdf.SetFont("Helvetica", "B", 9) + pdf.CellFormat(0, 6, " "+m.Module, "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 9) + if m.Err != "" { + pdf.SetTextColor(248, 81, 73) + pdf.MultiCell(0, 5, " error: "+truncate(m.Err, pdfMaxOutputChars), "", "L", false) + pdf.SetTextColor(0, 0, 0) + } else if m.Output != "" { + pdf.SetTextColor(60, 60, 60) + pdf.MultiCell(0, 5, " "+truncate(m.Output, pdfMaxOutputChars), "", "L", false) + pdf.SetTextColor(0, 0, 0) + } + } + pdf.Ln(2) + } + + writeHost(g.Root.Report.Host, rootReachable, g.Root.Report.Results, true) + for _, c := range g.Root.Children { + writeHost(c.Report.Host, c.Report.Reachable(), c.Report.Results, false) + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, sum, err + } + return buf.Bytes(), sum, nil +} + +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +}