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 }