code-intelligence.com/cifuzz@v0.40.0/internal/cmd/run/reporthandler/reporthandler.go (about)

     1  package reporthandler
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/gob"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  	"text/tabwriter"
    12  	"time"
    13  
    14  	"github.com/gookit/color"
    15  	"github.com/hokaccha/go-prettyjson"
    16  	"github.com/pkg/errors"
    17  	"github.com/pterm/pterm"
    18  	"golang.org/x/term"
    19  
    20  	"code-intelligence.com/cifuzz/internal/cmd/run/reporthandler/metrics"
    21  	"code-intelligence.com/cifuzz/internal/names"
    22  	"code-intelligence.com/cifuzz/pkg/desktop"
    23  	"code-intelligence.com/cifuzz/pkg/finding"
    24  	"code-intelligence.com/cifuzz/pkg/log"
    25  	"code-intelligence.com/cifuzz/pkg/report"
    26  	"code-intelligence.com/cifuzz/util/fileutil"
    27  	"code-intelligence.com/cifuzz/util/stringutil"
    28  )
    29  
    30  type ReportHandlerOptions struct {
    31  	ProjectDir           string
    32  	GeneratedCorpusDir   string
    33  	ManagedSeedCorpusDir string
    34  	UserSeedCorpusDirs   []string
    35  	BuildSystem          string
    36  	PrintJSON            bool
    37  }
    38  
    39  type ReportHandler struct {
    40  	*ReportHandlerOptions
    41  	usingUpdatingPrinter bool
    42  
    43  	printer      metrics.Printer
    44  	startedAt    time.Time
    45  	initStarted  bool
    46  	initFinished bool
    47  
    48  	LastMetrics  *report.FuzzingMetric
    49  	FirstMetrics *report.FuzzingMetric
    50  	ErrorDetails *[]finding.ErrorDetails
    51  
    52  	numSeedsAtInit uint
    53  
    54  	jsonOutput io.Writer
    55  
    56  	FuzzTest string
    57  	Findings []*finding.Finding
    58  }
    59  
    60  func NewReportHandler(fuzzTest string, options *ReportHandlerOptions) (*ReportHandler, error) {
    61  	var err error
    62  	h := &ReportHandler{
    63  		ReportHandlerOptions: options,
    64  		startedAt:            time.Now(),
    65  		jsonOutput:           os.Stdout,
    66  		FuzzTest:             fuzzTest,
    67  	}
    68  
    69  	// When --json was used, we don't want anything but JSON output on
    70  	// stdout, so we make the printer use stderr.
    71  	var printerOutput *os.File
    72  	if h.PrintJSON {
    73  		printerOutput = os.Stderr
    74  	} else {
    75  		printerOutput = os.Stdout
    76  	}
    77  
    78  	// Use an updating printer if the output stream is a TTY
    79  	// and plain style is not enabled
    80  	if term.IsTerminal(int(printerOutput.Fd())) && !log.PlainStyle() {
    81  		h.printer, err = metrics.NewUpdatingPrinter(printerOutput)
    82  		if err != nil {
    83  			return nil, err
    84  		}
    85  		h.usingUpdatingPrinter = true
    86  	} else {
    87  		h.printer = metrics.NewLinePrinter(printerOutput)
    88  	}
    89  
    90  	return h, nil
    91  }
    92  
    93  func (h *ReportHandler) Handle(r *report.Report) error {
    94  	var err error
    95  
    96  	if r.SeedCorpus != "" {
    97  		h.ManagedSeedCorpusDir = r.SeedCorpus
    98  	}
    99  
   100  	if r.GeneratedCorpus != "" {
   101  		h.GeneratedCorpusDir = r.GeneratedCorpus
   102  	}
   103  
   104  	if r.Status == report.RunStatusInitializing && !h.initStarted {
   105  		h.initStarted = true
   106  		h.numSeedsAtInit = r.NumSeeds
   107  		if r.NumSeeds == 0 {
   108  			log.Info("Starting from an empty corpus")
   109  			h.initFinished = true
   110  		} else {
   111  			log.Info("Initializing fuzzer with ", pterm.FgLightCyan.Sprintf("%d", r.NumSeeds), " seed inputs")
   112  		}
   113  	}
   114  
   115  	if r.Status == report.RunStatusRunning && !h.initFinished {
   116  		log.Info("Successfully initialized fuzzer with seed inputs")
   117  		h.initFinished = true
   118  	}
   119  
   120  	if r.Metric != nil {
   121  		h.LastMetrics = r.Metric
   122  		if h.FirstMetrics == nil {
   123  			h.FirstMetrics = r.Metric
   124  		}
   125  		h.printer.PrintMetrics(r.Metric)
   126  	}
   127  
   128  	if r.Finding != nil {
   129  		// save finding
   130  		h.Findings = append(h.Findings, r.Finding)
   131  
   132  		if len(h.Findings) == 1 {
   133  			h.PrintFindingInstruction()
   134  		}
   135  
   136  		err := h.handleFinding(r.Finding, !h.PrintJSON)
   137  		if err != nil {
   138  			return err
   139  		}
   140  	}
   141  
   142  	// Print report as JSON if the --json flag was specified
   143  	if h.PrintJSON {
   144  		var jsonString string
   145  		// Print with color if the output stream is a TTY
   146  		if file, ok := h.jsonOutput.(*os.File); !ok || !term.IsTerminal(int(file.Fd())) {
   147  			bytes, err := prettyjson.Marshal(r)
   148  			if err != nil {
   149  				return errors.WithStack(err)
   150  			}
   151  			jsonString = string(bytes)
   152  		} else {
   153  			jsonString, err = stringutil.ToJSONString(r)
   154  			if err != nil {
   155  				return err
   156  			}
   157  		}
   158  		if h.usingUpdatingPrinter {
   159  			// Clear the updating printer
   160  			h.printer.(*metrics.UpdatingPrinter).Clear()
   161  		}
   162  		_, _ = fmt.Fprintln(h.jsonOutput, jsonString)
   163  		return nil
   164  	}
   165  
   166  	return nil
   167  }
   168  
   169  func (h *ReportHandler) handleFinding(f *finding.Finding, print bool) error {
   170  	var err error
   171  
   172  	f.CreatedAt = time.Now()
   173  
   174  	// Generate a name for the finding. The name is chosen deterministically,
   175  	// based on:
   176  	// * Parts of the stack trace: The function name, source file name,
   177  	//   line and column of those stack frames which are located in user
   178  	//   or library code, i.e. everything above the call to
   179  	//   LLVMFuzzerTestOneInputNoReturn or LLVMFuzzerTestOneInput.
   180  	// * The crashing input.
   181  	//
   182  	// This automatically provides some very basic deduplication:
   183  	// Crashes which were triggered by the same line in the user code
   184  	// and with the same crashing input result in the same name, which
   185  	// means that a previous finding of the same name gets overwritten.
   186  	// So when executing the same fuzz test twice, we don't have
   187  	// duplicate findings, because the same crashing input is used from
   188  	// the seed corpus (unless the user deliberately removed it), which
   189  	// results in the same crash and a finding of the same name.
   190  	//
   191  	// By including the crashing input, we also generate a new finding
   192  	// in the scenario that, after a crash was found, the code was fixed
   193  	// and therefore the old crashing input does not trigger the crash
   194  	// anymore, but in a subsequent run the fuzzer finds a different
   195  	// crashing input which causes the crash again. We do want to
   196  	// produce a distinct new finding in that case.
   197  	var b bytes.Buffer
   198  	err = gob.NewEncoder(&b).Encode(f.StackTrace)
   199  	if err != nil {
   200  		return errors.WithStack(err)
   201  	}
   202  	nameSeed := append(b.Bytes(), f.InputData...)
   203  	f.Name = names.GetDeterministicName(nameSeed)
   204  
   205  	if f.InputFile != "" {
   206  		err = f.CopyInputFileAndUpdateFinding(h.ProjectDir, h.ManagedSeedCorpusDir, h.BuildSystem)
   207  		if err != nil {
   208  			return err
   209  		}
   210  	}
   211  
   212  	f.FuzzTest = h.FuzzTest
   213  
   214  	// Do not mutate f after this call.
   215  	err = f.Save(h.ProjectDir)
   216  	if err != nil {
   217  		return err
   218  	}
   219  
   220  	if !print {
   221  		return nil
   222  	}
   223  	log.Finding(f.ShortDescriptionWithName())
   224  
   225  	desktop.Notify("cifuzz finding", f.ShortDescriptionWithName())
   226  
   227  	return nil
   228  }
   229  
   230  func (h *ReportHandler) PrintFindingInstruction() {
   231  	log.Note(`
   232  Use 'cifuzz finding <finding name>' for details on a finding.
   233  
   234  `)
   235  }
   236  
   237  func (h *ReportHandler) PrintCrashingInputNote() {
   238  	var crashingInputs []string
   239  
   240  	for _, f := range h.Findings {
   241  		if f.GetSeedPath() != "" {
   242  			crashingInputs = append(crashingInputs, fileutil.PrettifyPath(f.GetSeedPath()))
   243  		}
   244  	}
   245  
   246  	if len(crashingInputs) == 0 {
   247  		return
   248  	}
   249  
   250  	log.Notef(`
   251  Note: The reproducing inputs have been copied to the seed corpus at:
   252  
   253      %s
   254  
   255  They will now be used as a seed input for all runs of the fuzz test,
   256  including remote runs with artifacts created via 'cifuzz bundle' and
   257  regression tests. For more information on regression tests, see:
   258  
   259      https://github.com/CodeIntelligenceTesting/cifuzz/blob/main/docs/Regression-Testing.md
   260  `, strings.Join(crashingInputs, "\n    "))
   261  }
   262  
   263  func (h *ReportHandler) PrintFinalMetrics() error {
   264  	// We don't want to print colors to stderr unless it's a TTY
   265  	if !term.IsTerminal(int(os.Stderr.Fd())) {
   266  		color.Disable()
   267  	}
   268  
   269  	if h.usingUpdatingPrinter {
   270  		// Stop the updating printer
   271  		updatingPrinter := h.printer.(*metrics.UpdatingPrinter)
   272  		err := updatingPrinter.Stop()
   273  		if err != nil {
   274  			return errors.WithStack(err)
   275  		}
   276  	} else {
   277  		// Stopping the updating printer leaves an empty line, which
   278  		// we actually want before the final metrics (because it looks
   279  		// better), so in case we did not use an updating printer,
   280  		// print an empty line anyway.
   281  		log.Print("\n")
   282  	}
   283  
   284  	numCorpusEntries, err := h.countCorpusEntries()
   285  	if err != nil {
   286  		return err
   287  	}
   288  
   289  	duration := time.Since(h.startedAt)
   290  	newCorpusEntries := numCorpusEntries - h.numSeedsAtInit
   291  
   292  	// If the number of new corpus entries exceeds the total corpus entries, it
   293  	// indicates an unexpected scenario where the total corpus entries are zero
   294  	// (e.g., when running with `--engine-arg=-runs=10`) and cifuzz discovers new
   295  	// seeds during subsequent runs. To avoid any issues related to unsigned
   296  	// integers, we set the new corpus entries to 0 in such cases.
   297  	if newCorpusEntries > numCorpusEntries {
   298  		newCorpusEntries = 0
   299  	}
   300  
   301  	var averageExecsStr string
   302  
   303  	if h.FirstMetrics == nil {
   304  		averageExecsStr = metrics.NumberString("n/a")
   305  	} else {
   306  		var averageExecs uint64
   307  		metricsDuration := h.LastMetrics.Timestamp.Sub(h.FirstMetrics.Timestamp)
   308  		if metricsDuration.Milliseconds() == 0 {
   309  			// The first and last metrics are either the same or were
   310  			// printed too fast one after the other to calculate a
   311  			// meaningful average, so we just use the exec/s from the
   312  			// current metrics as the average.
   313  			averageExecs = uint64(h.LastMetrics.ExecutionsPerSecond)
   314  		} else {
   315  			// We use milliseconds here to calculate a more accurate average
   316  			execs := h.LastMetrics.TotalExecutions - h.FirstMetrics.TotalExecutions
   317  			averageExecs = uint64(float64(execs) / (float64(metricsDuration.Milliseconds()) / 1000))
   318  		}
   319  		averageExecsStr = metrics.NumberString("%d", averageExecs)
   320  	}
   321  
   322  	// Round towards the next larger second to avoid that very short
   323  	// runs show "Ran for 0s".
   324  	durationStr := (duration.Truncate(time.Second) + time.Second).String()
   325  
   326  	lines := []string{
   327  		metrics.DescString("Execution time:\t") + metrics.NumberString(durationStr),
   328  		metrics.DescString("Average exec/s:\t") + averageExecsStr,
   329  		metrics.DescString("Findings:\t") + metrics.NumberString("%d", len(h.Findings)),
   330  		metrics.DescString("Corpus entries:\t") + metrics.NumberString("%d", numCorpusEntries) +
   331  			metrics.DescString(" (+%s)", metrics.NumberString("%d", newCorpusEntries)),
   332  	}
   333  
   334  	w := tabwriter.NewWriter(log.NewPTermWriter(os.Stderr), 0, 0, 1, ' ', 0)
   335  	for _, line := range lines {
   336  		_, err := fmt.Fprintln(w, line)
   337  		if err != nil {
   338  			return errors.WithStack(err)
   339  		}
   340  	}
   341  	err = w.Flush()
   342  	if err != nil {
   343  		return errors.WithStack(err)
   344  	}
   345  
   346  	return nil
   347  }
   348  
   349  func (h *ReportHandler) countCorpusEntries() (uint, error) {
   350  	var numSeeds uint
   351  	seedCorpusDirs := append(h.UserSeedCorpusDirs, h.ManagedSeedCorpusDir, h.GeneratedCorpusDir)
   352  
   353  	for _, dir := range seedCorpusDirs {
   354  		var seedsInDir uint
   355  		err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
   356  			if err != nil {
   357  				return err
   358  			}
   359  			if d.IsDir() {
   360  				return nil
   361  			}
   362  			info, err := d.Info()
   363  			if err != nil {
   364  				return err
   365  			}
   366  			// Don't count empty files, same as libFuzzer
   367  			if info.Size() != 0 {
   368  				seedsInDir += 1
   369  			}
   370  			return nil
   371  		})
   372  		// Don't fail if the seed corpus dir doesn't exist
   373  		if os.IsNotExist(err) {
   374  			return 0, nil
   375  		}
   376  		if err != nil {
   377  			return 0, errors.WithStack(err)
   378  		}
   379  		numSeeds += seedsInDir
   380  	}
   381  	return numSeeds, nil
   382  }