text.go (15427B)
1 // Copyright 2022 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package scan 6 7 import ( 8 "fmt" 9 "io" 10 "sort" 11 "strings" 12 13 "golang.org/x/vuln/internal" 14 "golang.org/x/vuln/internal/govulncheck" 15 "golang.org/x/vuln/internal/osv" 16 "golang.org/x/vuln/internal/vulncheck" 17 ) 18 19 type style int 20 21 const ( 22 defaultStyle = style(iota) 23 osvCalledStyle 24 osvImportedStyle 25 detailsStyle 26 sectionStyle 27 keyStyle 28 valueStyle 29 ) 30 31 // NewtextHandler returns a handler that writes govulncheck output as text. 32 func NewTextHandler(w io.Writer) *TextHandler { 33 return &TextHandler{w: w} 34 } 35 36 type TextHandler struct { 37 w io.Writer 38 sbom *govulncheck.SBOM 39 osvs []*osv.Entry 40 findings []*findingSummary 41 scanLevel govulncheck.ScanLevel 42 scanMode govulncheck.ScanMode 43 44 err error 45 46 showColor bool 47 showTraces bool 48 showVersion bool 49 showVerbose bool 50 } 51 52 const ( 53 detailsMessage = `For details, see https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck.` 54 55 binaryProgressMessage = `Scanning your binary for known vulnerabilities...` 56 57 noVulnsMessage = `No vulnerabilities found.` 58 59 noOtherVulnsMessage = `No other vulnerabilities found.` 60 61 verboseMessage = `'-show verbose' for more details` 62 63 symbolMessage = `'-scan symbol' for more fine grained vulnerability detection` 64 ) 65 66 func (h *TextHandler) Flush() error { 67 if h.showVerbose { 68 h.printSBOM() 69 } 70 if len(h.findings) == 0 { 71 h.print(noVulnsMessage + "\n") 72 } else { 73 fixupFindings(h.osvs, h.findings) 74 counters := h.allVulns(h.findings) 75 h.summary(counters) 76 } 77 if h.err != nil { 78 return h.err 79 } 80 // We found vulnerabilities when the findings' level matches the scan level. 81 if (isCalled(h.findings) && h.scanLevel == govulncheck.ScanLevelSymbol) || 82 (isImported(h.findings) && h.scanLevel == govulncheck.ScanLevelPackage) || 83 (isRequired(h.findings) && h.scanLevel == govulncheck.ScanLevelModule) { 84 return errVulnerabilitiesFound 85 } 86 87 return nil 88 } 89 90 // Config writes version information only if --version was set. 91 func (h *TextHandler) Config(config *govulncheck.Config) error { 92 h.scanLevel = config.ScanLevel 93 h.scanMode = config.ScanMode 94 95 if !h.showVersion { 96 return nil 97 } 98 if config.GoVersion != "" { 99 h.style(keyStyle, "Go: ") 100 h.print(config.GoVersion, "\n") 101 } 102 if config.ScannerName != "" { 103 h.style(keyStyle, "Scanner: ") 104 h.print(config.ScannerName) 105 if config.ScannerVersion != "" { 106 h.print(`@`, config.ScannerVersion) 107 } 108 h.print("\n") 109 } 110 if config.DB != "" { 111 h.style(keyStyle, "DB: ") 112 h.print(config.DB, "\n") 113 if config.DBLastModified != nil { 114 h.style(keyStyle, "DB updated: ") 115 h.print(*config.DBLastModified, "\n") 116 } 117 } 118 h.print("\n") 119 return h.err 120 } 121 122 func (h *TextHandler) SBOM(sbom *govulncheck.SBOM) error { 123 h.sbom = sbom 124 return nil 125 } 126 127 func (h *TextHandler) printSBOM() error { 128 if h.sbom == nil { 129 h.print("No packages matched the provided pattern.\n") 130 return nil 131 } 132 133 printed := false 134 135 for i, root := range h.sbom.Roots { 136 if i == 0 { 137 if len(h.sbom.Roots) > 1 { 138 h.print("The package pattern matched the following ", len(h.sbom.Roots), " root packages:\n") 139 } else { 140 h.print("The package pattern matched the following root package:\n") 141 } 142 } 143 144 h.print(" ", root, "\n") 145 printed = true 146 } 147 for i, mod := range h.sbom.Modules { 148 if i == 0 && mod.Path != "stdlib" { 149 h.print("Govulncheck scanned the following ", len(h.sbom.Modules)-1, " modules and the ", h.sbom.GoVersion, " standard library:\n") 150 } 151 152 if mod.Path == "stdlib" { 153 continue 154 } 155 156 h.print(" ", mod.Path) 157 if mod.Version != "" { 158 h.print("@", mod.Version) 159 } 160 h.print("\n") 161 printed = true 162 } 163 if printed { 164 h.print("\n") 165 } 166 return nil 167 } 168 169 // Progress writes progress updates during govulncheck execution. 170 func (h *TextHandler) Progress(progress *govulncheck.Progress) error { 171 if h.showVerbose { 172 h.print(progress.Message, "\n\n") 173 } 174 return h.err 175 } 176 177 // OSV gathers osv entries to be written. 178 func (h *TextHandler) OSV(entry *osv.Entry) error { 179 h.osvs = append(h.osvs, entry) 180 return nil 181 } 182 183 // Finding gathers vulnerability findings to be written. 184 func (h *TextHandler) Finding(finding *govulncheck.Finding) error { 185 if err := validateFindings(finding); err != nil { 186 return err 187 } 188 h.findings = append(h.findings, newFindingSummary(finding)) 189 return nil 190 } 191 192 func (h *TextHandler) allVulns(findings []*findingSummary) summaryCounters { 193 byVuln := groupByVuln(findings) 194 var called, imported, required [][]*findingSummary 195 mods := map[string]struct{}{} 196 stdlibCalled := false 197 for _, findings := range byVuln { 198 switch { 199 case isCalled(findings): 200 called = append(called, findings) 201 if isStdFindings(findings) { 202 stdlibCalled = true 203 } else { 204 mods[findings[0].Trace[0].Module] = struct{}{} 205 } 206 case isImported(findings): 207 imported = append(imported, findings) 208 default: 209 required = append(required, findings) 210 } 211 } 212 213 if h.scanLevel.WantSymbols() { 214 h.style(sectionStyle, "=== Symbol Results ===\n\n") 215 if len(called) == 0 { 216 h.print(noVulnsMessage, "\n\n") 217 } 218 for index, findings := range called { 219 h.vulnerability(index, findings) 220 } 221 } 222 223 if h.scanLevel == govulncheck.ScanLevelPackage || (h.scanLevel.WantPackages() && h.showVerbose) { 224 h.style(sectionStyle, "=== Package Results ===\n\n") 225 if len(imported) == 0 { 226 h.print(choose(!h.scanLevel.WantSymbols(), noVulnsMessage, noOtherVulnsMessage), "\n\n") 227 } 228 for index, findings := range imported { 229 h.vulnerability(index, findings) 230 } 231 } 232 233 if h.showVerbose || h.scanLevel == govulncheck.ScanLevelModule { 234 h.style(sectionStyle, "=== Module Results ===\n\n") 235 if len(required) == 0 { 236 h.print(choose(!h.scanLevel.WantPackages(), noVulnsMessage, noOtherVulnsMessage), "\n\n") 237 } 238 for index, findings := range required { 239 h.vulnerability(index, findings) 240 } 241 } 242 243 return summaryCounters{ 244 VulnerabilitiesCalled: len(called), 245 VulnerabilitiesImported: len(imported), 246 VulnerabilitiesRequired: len(required), 247 ModulesCalled: len(mods), 248 StdlibCalled: stdlibCalled, 249 } 250 } 251 252 func (h *TextHandler) vulnerability(index int, findings []*findingSummary) { 253 h.style(keyStyle, "Vulnerability") 254 h.print(" #", index+1, ": ") 255 if isCalled(findings) { 256 h.style(osvCalledStyle, findings[0].OSV.ID) 257 } else { 258 h.style(osvImportedStyle, findings[0].OSV.ID) 259 } 260 h.print("\n") 261 h.style(detailsStyle) 262 description := findings[0].OSV.Summary 263 if description == "" { 264 description = findings[0].OSV.Details 265 } 266 h.wrap(" ", description, 80) 267 h.style(defaultStyle) 268 h.print("\n") 269 h.style(keyStyle, " More info:") 270 h.print(" ", findings[0].OSV.DatabaseSpecific.URL, "\n") 271 272 byModule := groupByModule(findings) 273 first := true 274 for _, module := range byModule { 275 // Note: there can be several findingSummaries for the same vulnerability 276 // emitted during streaming for different scan levels. 277 278 // The module is same for all finding summaries. 279 lastFrame := module[0].Trace[0] 280 mod := lastFrame.Module 281 // For stdlib, try to show package path as module name where 282 // the scan level allows it. 283 // TODO: should this be done in byModule as well? 284 path := lastFrame.Module 285 if stdPkg := h.pkg(module); path == internal.GoStdModulePath && stdPkg != "" { 286 path = stdPkg 287 } 288 // All findings on a module are found and fixed at the same version 289 foundVersion := moduleVersionString(lastFrame.Module, lastFrame.Version) 290 fixedVersion := moduleVersionString(lastFrame.Module, module[0].FixedVersion) 291 if !first { 292 h.print("\n") 293 } 294 first = false 295 h.print(" ") 296 if mod == internal.GoStdModulePath { 297 h.print("Standard library") 298 } else { 299 h.style(keyStyle, "Module: ") 300 h.print(mod) 301 } 302 h.print("\n ") 303 h.style(keyStyle, "Found in: ") 304 h.print(path, "@", foundVersion, "\n ") 305 h.style(keyStyle, "Fixed in: ") 306 if fixedVersion != "" { 307 h.print(path, "@", fixedVersion) 308 } else { 309 h.print("N/A") 310 } 311 h.print("\n") 312 platforms := platforms(mod, module[0].OSV) 313 if len(platforms) > 0 { 314 h.style(keyStyle, " Platforms: ") 315 for ip, p := range platforms { 316 if ip > 0 { 317 h.print(", ") 318 } 319 h.print(p) 320 } 321 h.print("\n") 322 } 323 h.traces(module) 324 } 325 h.print("\n") 326 } 327 328 // pkg gives the package information for findings summaries 329 // if one exists. This is only used to print package path 330 // instead of a module for stdlib vulnerabilities at symbol 331 // and package scan level. 332 func (h *TextHandler) pkg(summaries []*findingSummary) string { 333 for _, f := range summaries { 334 if pkg := f.Trace[0].Package; pkg != "" { 335 return pkg 336 } 337 } 338 return "" 339 } 340 341 // traces prints out the most precise trace information 342 // found in the given summaries. 343 func (h *TextHandler) traces(traces []*findingSummary) { 344 // Sort the traces by the vulnerable symbol. This 345 // guarantees determinism since we are currently 346 // showing only one trace per symbol. 347 sort.SliceStable(traces, func(i, j int) bool { 348 return symbol(traces[i].Trace[0], true) < symbol(traces[j].Trace[0], true) 349 }) 350 351 // compacts are finding summaries with compact traces 352 // suitable for non-verbose textual output. Currently, 353 // only traces produced by symbol analysis. 354 var compacts []*findingSummary 355 for _, t := range traces { 356 if t.Compact != "" { 357 compacts = append(compacts, t) 358 } 359 } 360 361 // binLimit is a limit on the number of binary traces 362 // to show. Traces for binaries are less interesting 363 // as users cannot act on them and they can hence 364 // spam users. 365 const binLimit = 5 366 binary := h.scanMode == govulncheck.ScanModeBinary 367 for i, entry := range compacts { 368 if i == 0 { 369 if binary { 370 h.style(keyStyle, " Vulnerable symbols found:\n") 371 } else { 372 h.style(keyStyle, " Example traces found:\n") 373 } 374 } 375 376 // skip showing all symbols in binary mode unless '-show traces' is on. 377 if binary && (i+1) > binLimit && !h.showTraces { 378 h.print(" Use '-show traces' to see the other ", len(compacts)-binLimit, " found symbols\n") 379 break 380 } 381 382 h.print(" #", i+1, ": ") 383 384 if !h.showTraces { // show summarized traces 385 h.print(entry.Compact, "\n") 386 continue 387 } 388 389 if binary { 390 // There are no call stacks in binary mode 391 // so just show the full symbol name. 392 h.print(symbol(entry.Trace[0], false), "\n") 393 } else { 394 h.print("for function ", symbol(entry.Trace[0], false), "\n") 395 for i := len(entry.Trace) - 1; i >= 0; i-- { 396 t := entry.Trace[i] 397 h.print(" ") 398 h.print(symbolName(t)) 399 if t.Position != nil { 400 h.print(" @ ", symbolPath(t)) 401 } 402 h.print("\n") 403 } 404 } 405 } 406 } 407 408 // symbolPath returns a user-friendly path to a symbol. 409 func symbolPath(t *govulncheck.Frame) string { 410 // Add module path prefix to symbol paths to be more 411 // explicit to which module the symbols belong to. 412 return t.Module + "/" + posToString(t.Position) 413 } 414 415 func (h *TextHandler) summary(c summaryCounters) { 416 // print short summary of findings identified at the desired level of scan precision 417 var vulnCount int 418 h.print("Your code ", choose(h.scanLevel.WantSymbols(), "is", "may be"), " affected by ") 419 switch h.scanLevel { 420 case govulncheck.ScanLevelSymbol: 421 vulnCount = c.VulnerabilitiesCalled 422 case govulncheck.ScanLevelPackage: 423 vulnCount = c.VulnerabilitiesImported 424 case govulncheck.ScanLevelModule: 425 vulnCount = c.VulnerabilitiesRequired 426 } 427 h.style(valueStyle, vulnCount) 428 h.print(choose(vulnCount == 1, ` vulnerability`, ` vulnerabilities`)) 429 if h.scanLevel.WantSymbols() { 430 h.print(choose(c.ModulesCalled > 0 || c.StdlibCalled, ` from `, ``)) 431 if c.ModulesCalled > 0 { 432 h.style(valueStyle, c.ModulesCalled) 433 h.print(choose(c.ModulesCalled == 1, ` module`, ` modules`)) 434 } 435 if c.StdlibCalled { 436 if c.ModulesCalled != 0 { 437 h.print(` and `) 438 } 439 h.print(`the Go standard library`) 440 } 441 } 442 h.print(".\n") 443 444 // print summary for vulnerabilities found at other levels of scan precision 445 if other := h.summaryOtherVulns(c); other != "" { 446 h.wrap("", other, 80) 447 h.print("\n") 448 } 449 450 // print suggested flags for more/better info depending on scan level and if in verbose mode 451 if sugg := h.summarySuggestion(); sugg != "" { 452 h.wrap("", sugg, 80) 453 h.print("\n") 454 } 455 } 456 457 func (h *TextHandler) summaryOtherVulns(c summaryCounters) string { 458 var summary strings.Builder 459 if c.VulnerabilitiesRequired+c.VulnerabilitiesImported == 0 { 460 summary.WriteString("This scan found no other vulnerabilities in ") 461 if h.scanLevel.WantSymbols() { 462 summary.WriteString("packages you import or ") 463 } 464 summary.WriteString("modules you require.") 465 } else { 466 summary.WriteString(choose(h.scanLevel.WantPackages(), "This scan also found ", "")) 467 if h.scanLevel.WantSymbols() { 468 summary.WriteString(fmt.Sprint(c.VulnerabilitiesImported)) 469 summary.WriteString(choose(c.VulnerabilitiesImported == 1, ` vulnerability `, ` vulnerabilities `)) 470 summary.WriteString("in packages you import and ") 471 } 472 if h.scanLevel.WantPackages() { 473 summary.WriteString(fmt.Sprint(c.VulnerabilitiesRequired)) 474 summary.WriteString(choose(c.VulnerabilitiesRequired == 1, ` vulnerability `, ` vulnerabilities `)) 475 summary.WriteString("in modules you require") 476 summary.WriteString(choose(h.scanLevel.WantSymbols(), ", but your code doesn't appear to call these vulnerabilities.", ".")) 477 } 478 } 479 return summary.String() 480 } 481 482 func (h *TextHandler) summarySuggestion() string { 483 var sugg strings.Builder 484 switch h.scanLevel { 485 case govulncheck.ScanLevelSymbol: 486 if !h.showVerbose { 487 sugg.WriteString("Use " + verboseMessage + ".") 488 } 489 case govulncheck.ScanLevelPackage: 490 sugg.WriteString("Use " + symbolMessage) 491 if !h.showVerbose { 492 sugg.WriteString(" and " + verboseMessage) 493 } 494 sugg.WriteString(".") 495 case govulncheck.ScanLevelModule: 496 sugg.WriteString("Use " + symbolMessage + ".") 497 } 498 return sugg.String() 499 } 500 501 func (h *TextHandler) style(style style, values ...any) { 502 if h.showColor { 503 switch style { 504 default: 505 h.print(colorReset) 506 case osvCalledStyle: 507 h.print(colorBold, fgRed) 508 case osvImportedStyle: 509 h.print(colorBold, fgGreen) 510 case detailsStyle: 511 h.print(colorFaint) 512 case sectionStyle: 513 h.print(fgBlue) 514 case keyStyle: 515 h.print(colorFaint, fgYellow) 516 case valueStyle: 517 h.print(colorBold, fgCyan) 518 } 519 } 520 h.print(values...) 521 if h.showColor && len(values) > 0 { 522 h.print(colorReset) 523 } 524 } 525 526 func (h *TextHandler) print(values ...any) int { 527 total, w := 0, 0 528 for _, v := range values { 529 if h.err != nil { 530 return total 531 } 532 // do we need to specialize for some types, like time? 533 w, h.err = fmt.Fprint(h.w, v) 534 total += w 535 } 536 return total 537 } 538 539 // wrap wraps s to fit in maxWidth by breaking it into lines at whitespace. If a 540 // single word is longer than maxWidth, it is retained as its own line. 541 func (h *TextHandler) wrap(indent string, s string, maxWidth int) { 542 w := 0 543 for _, f := range strings.Fields(s) { 544 if w > 0 && w+len(f)+1 > maxWidth { 545 // line would be too long with this word 546 h.print("\n") 547 w = 0 548 } 549 if w == 0 { 550 // first field on line, indent 551 w = h.print(indent) 552 } else { 553 // not first word, space separate 554 w += h.print(" ") 555 } 556 // now write the word 557 w += h.print(f) 558 } 559 } 560 561 func choose[t any](b bool, yes, no t) t { 562 if b { 563 return yes 564 } 565 return no 566 } 567 568 func isStdFindings(findings []*findingSummary) bool { 569 for _, f := range findings { 570 if vulncheck.IsStdPackage(f.Trace[0].Package) || f.Trace[0].Module == internal.GoStdModulePath { 571 return true 572 } 573 } 574 return false 575 }