| // Copyright 2022 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package scan | |
| import ( | |
| "fmt" | |
| "io" | |
| "sort" | |
| "strings" | |
| "golang.org/x/vuln/internal" | |
| "golang.org/x/vuln/internal/govulncheck" | |
| "golang.org/x/vuln/internal/osv" | |
| "golang.org/x/vuln/internal/vulncheck" | |
| ) | |
| type style int | |
| const ( | |
| defaultStyle = style(iota) | |
| osvCalledStyle | |
| osvImportedStyle | |
| detailsStyle | |
| sectionStyle | |
| keyStyle | |
| valueStyle | |
| ) | |
| // NewtextHandler returns a handler that writes govulncheck output as text. | |
| func NewTextHandler(w io.Writer) *TextHandler { | |
| return &TextHandler{w: w} | |
| } | |
| type TextHandler struct { | |
| w io.Writer | |
| sbom *govulncheck.SBOM | |
| osvs []*osv.Entry | |
| findings []*findingSummary | |
| scanLevel govulncheck.ScanLevel | |
| scanMode govulncheck.ScanMode | |
| err error | |
| showColor bool | |
| showTraces bool | |
| showVersion bool | |
| showVerbose bool | |
| } | |
| const ( | |
| detailsMessage = `For details, see https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck.` | |
| binaryProgressMessage = `Scanning your binary for known vulnerabilities...` | |
| noVulnsMessage = `No vulnerabilities found.` | |
| noOtherVulnsMessage = `No other vulnerabilities found.` | |
| verboseMessage = `'-show verbose' for more details` | |
| symbolMessage = `'-scan symbol' for more fine grained vulnerability detection` | |
| ) | |
| func (h *TextHandler) Flush() error { | |
| if h.showVerbose { | |
| h.printSBOM() | |
| } | |
| if len(h.findings) == 0 { | |
| h.print(noVulnsMessage + "\n") | |
| } else { | |
| fixupFindings(h.osvs, h.findings) | |
| counters := h.allVulns(h.findings) | |
| h.summary(counters) | |
| } | |
| if h.err != nil { | |
| return h.err | |
| } | |
| // We found vulnerabilities when the findings' level matches the scan level. | |
| if (isCalled(h.findings) && h.scanLevel == govulncheck.ScanLevelSymbol) || | |
| (isImported(h.findings) && h.scanLevel == govulncheck.ScanLevelPackage) || | |
| (isRequired(h.findings) && h.scanLevel == govulncheck.ScanLevelModule) { | |
| return errVulnerabilitiesFound | |
| } | |
| return nil | |
| } | |
| // Config writes version information only if --version was set. | |
| func (h *TextHandler) Config(config *govulncheck.Config) error { | |
| h.scanLevel = config.ScanLevel | |
| h.scanMode = config.ScanMode | |
| if !h.showVersion { | |
| return nil | |
| } | |
| if config.GoVersion != "" { | |
| h.style(keyStyle, "Go: ") | |
| h.print(config.GoVersion, "\n") | |
| } | |
| if config.ScannerName != "" { | |
| h.style(keyStyle, "Scanner: ") | |
| h.print(config.ScannerName) | |
| if config.ScannerVersion != "" { | |
| h.print(`@`, config.ScannerVersion) | |
| } | |
| h.print("\n") | |
| } | |
| if config.DB != "" { | |
| h.style(keyStyle, "DB: ") | |
| h.print(config.DB, "\n") | |
| if config.DBLastModified != nil { | |
| h.style(keyStyle, "DB updated: ") | |
| h.print(*config.DBLastModified, "\n") | |
| } | |
| } | |
| h.print("\n") | |
| return h.err | |
| } | |
| func (h *TextHandler) SBOM(sbom *govulncheck.SBOM) error { | |
| h.sbom = sbom | |
| return nil | |
| } | |
| func (h *TextHandler) printSBOM() error { | |
| if h.sbom == nil { | |
| h.print("No packages matched the provided pattern.\n") | |
| return nil | |
| } | |
| printed := false | |
| for i, root := range h.sbom.Roots { | |
| if i == 0 { | |
| if len(h.sbom.Roots) > 1 { | |
| h.print("The package pattern matched the following ", len(h.sbom.Roots), " root packages:\n") | |
| } else { | |
| h.print("The package pattern matched the following root package:\n") | |
| } | |
| } | |
| h.print(" ", root, "\n") | |
| printed = true | |
| } | |
| for i, mod := range h.sbom.Modules { | |
| if i == 0 && mod.Path != "stdlib" { | |
| h.print("Govulncheck scanned the following ", len(h.sbom.Modules)-1, " modules and the ", h.sbom.GoVersion, " standard library:\n") | |
| } | |
| if mod.Path == "stdlib" { | |
| continue | |
| } | |
| h.print(" ", mod.Path) | |
| if mod.Version != "" { | |
| h.print("@", mod.Version) | |
| } | |
| h.print("\n") | |
| printed = true | |
| } | |
| if printed { | |
| h.print("\n") | |
| } | |
| return nil | |
| } | |
| // Progress writes progress updates during govulncheck execution. | |
| func (h *TextHandler) Progress(progress *govulncheck.Progress) error { | |
| if h.showVerbose { | |
| h.print(progress.Message, "\n\n") | |
| } | |
| return h.err | |
| } | |
| // OSV gathers osv entries to be written. | |
| func (h *TextHandler) OSV(entry *osv.Entry) error { | |
| h.osvs = append(h.osvs, entry) | |
| return nil | |
| } | |
| // Finding gathers vulnerability findings to be written. | |
| func (h *TextHandler) Finding(finding *govulncheck.Finding) error { | |
| if err := validateFindings(finding); err != nil { | |
| return err | |
| } | |
| h.findings = append(h.findings, newFindingSummary(finding)) | |
| return nil | |
| } | |
| func (h *TextHandler) allVulns(findings []*findingSummary) summaryCounters { | |
| byVuln := groupByVuln(findings) | |
| var called, imported, required [][]*findingSummary | |
| mods := map[string]struct{}{} | |
| stdlibCalled := false | |
| for _, findings := range byVuln { | |
| switch { | |
| case isCalled(findings): | |
| called = append(called, findings) | |
| if isStdFindings(findings) { | |
| stdlibCalled = true | |
| } else { | |
| mods[findings[0].Trace[0].Module] = struct{}{} | |
| } | |
| case isImported(findings): | |
| imported = append(imported, findings) | |
| default: | |
| required = append(required, findings) | |
| } | |
| } | |
| if h.scanLevel.WantSymbols() { | |
| h.style(sectionStyle, "=== Symbol Results ===\n\n") | |
| if len(called) == 0 { | |
| h.print(noVulnsMessage, "\n\n") | |
| } | |
| for index, findings := range called { | |
| h.vulnerability(index, findings) | |
| } | |
| } | |
| if h.scanLevel == govulncheck.ScanLevelPackage || (h.scanLevel.WantPackages() && h.showVerbose) { | |
| h.style(sectionStyle, "=== Package Results ===\n\n") | |
| if len(imported) == 0 { | |
| h.print(choose(!h.scanLevel.WantSymbols(), noVulnsMessage, noOtherVulnsMessage), "\n\n") | |
| } | |
| for index, findings := range imported { | |
| h.vulnerability(index, findings) | |
| } | |
| } | |
| if h.showVerbose || h.scanLevel == govulncheck.ScanLevelModule { | |
| h.style(sectionStyle, "=== Module Results ===\n\n") | |
| if len(required) == 0 { | |
| h.print(choose(!h.scanLevel.WantPackages(), noVulnsMessage, noOtherVulnsMessage), "\n\n") | |
| } | |
| for index, findings := range required { | |
| h.vulnerability(index, findings) | |
| } | |
| } | |
| return summaryCounters{ | |
| VulnerabilitiesCalled: len(called), | |
| VulnerabilitiesImported: len(imported), | |
| VulnerabilitiesRequired: len(required), | |
| ModulesCalled: len(mods), | |
| StdlibCalled: stdlibCalled, | |
| } | |
| } | |
| func (h *TextHandler) vulnerability(index int, findings []*findingSummary) { | |
| h.style(keyStyle, "Vulnerability") | |
| h.print(" #", index+1, ": ") | |
| if isCalled(findings) { | |
| h.style(osvCalledStyle, findings[0].OSV.ID) | |
| } else { | |
| h.style(osvImportedStyle, findings[0].OSV.ID) | |
| } | |
| h.print("\n") | |
| h.style(detailsStyle) | |
| description := findings[0].OSV.Summary | |
| if description == "" { | |
| description = findings[0].OSV.Details | |
| } | |
| h.wrap(" ", description, 80) | |
| h.style(defaultStyle) | |
| h.print("\n") | |
| h.style(keyStyle, " More info:") | |
| h.print(" ", findings[0].OSV.DatabaseSpecific.URL, "\n") | |
| byModule := groupByModule(findings) | |
| first := true | |
| for _, module := range byModule { | |
| // Note: there can be several findingSummaries for the same vulnerability | |
| // emitted during streaming for different scan levels. | |
| // The module is same for all finding summaries. | |
| lastFrame := module[0].Trace[0] | |
| mod := lastFrame.Module | |
| // For stdlib, try to show package path as module name where | |
| // the scan level allows it. | |
| // TODO: should this be done in byModule as well? | |
| path := lastFrame.Module | |
| if stdPkg := h.pkg(module); path == internal.GoStdModulePath && stdPkg != "" { | |
| path = stdPkg | |
| } | |
| // All findings on a module are found and fixed at the same version | |
| foundVersion := moduleVersionString(lastFrame.Module, lastFrame.Version) | |
| fixedVersion := moduleVersionString(lastFrame.Module, module[0].FixedVersion) | |
| if !first { | |
| h.print("\n") | |
| } | |
| first = false | |
| h.print(" ") | |
| if mod == internal.GoStdModulePath { | |
| h.print("Standard library") | |
| } else { | |
| h.style(keyStyle, "Module: ") | |
| h.print(mod) | |
| } | |
| h.print("\n ") | |
| h.style(keyStyle, "Found in: ") | |
| h.print(path, "@", foundVersion, "\n ") | |
| h.style(keyStyle, "Fixed in: ") | |
| if fixedVersion != "" { | |
| h.print(path, "@", fixedVersion) | |
| } else { | |
| h.print("N/A") | |
| } | |
| h.print("\n") | |
| platforms := platforms(mod, module[0].OSV) | |
| if len(platforms) > 0 { | |
| h.style(keyStyle, " Platforms: ") | |
| for ip, p := range platforms { | |
| if ip > 0 { | |
| h.print(", ") | |
| } | |
| h.print(p) | |
| } | |
| h.print("\n") | |
| } | |
| h.traces(module) | |
| } | |
| h.print("\n") | |
| } | |
| // pkg gives the package information for findings summaries | |
| // if one exists. This is only used to print package path | |
| // instead of a module for stdlib vulnerabilities at symbol | |
| // and package scan level. | |
| func (h *TextHandler) pkg(summaries []*findingSummary) string { | |
| for _, f := range summaries { | |
| if pkg := f.Trace[0].Package; pkg != "" { | |
| return pkg | |
| } | |
| } | |
| return "" | |
| } | |
| // traces prints out the most precise trace information | |
| // found in the given summaries. | |
| func (h *TextHandler) traces(traces []*findingSummary) { | |
| // Sort the traces by the vulnerable symbol. This | |
| // guarantees determinism since we are currently | |
| // showing only one trace per symbol. | |
| sort.SliceStable(traces, func(i, j int) bool { | |
| return symbol(traces[i].Trace[0], true) < symbol(traces[j].Trace[0], true) | |
| }) | |
| // compacts are finding summaries with compact traces | |
| // suitable for non-verbose textual output. Currently, | |
| // only traces produced by symbol analysis. | |
| var compacts []*findingSummary | |
| for _, t := range traces { | |
| if t.Compact != "" { | |
| compacts = append(compacts, t) | |
| } | |
| } | |
| // binLimit is a limit on the number of binary traces | |
| // to show. Traces for binaries are less interesting | |
| // as users cannot act on them and they can hence | |
| // spam users. | |
| const binLimit = 5 | |
| binary := h.scanMode == govulncheck.ScanModeBinary | |
| for i, entry := range compacts { | |
| if i == 0 { | |
| if binary { | |
| h.style(keyStyle, " Vulnerable symbols found:\n") | |
| } else { | |
| h.style(keyStyle, " Example traces found:\n") | |
| } | |
| } | |
| // skip showing all symbols in binary mode unless '-show traces' is on. | |
| if binary && (i+1) > binLimit && !h.showTraces { | |
| h.print(" Use '-show traces' to see the other ", len(compacts)-binLimit, " found symbols\n") | |
| break | |
| } | |
| h.print(" #", i+1, ": ") | |
| if !h.showTraces { // show summarized traces | |
| h.print(entry.Compact, "\n") | |
| continue | |
| } | |
| if binary { | |
| // There are no call stacks in binary mode | |
| // so just show the full symbol name. | |
| h.print(symbol(entry.Trace[0], false), "\n") | |
| } else { | |
| h.print("for function ", symbol(entry.Trace[0], false), "\n") | |
| for i := len(entry.Trace) - 1; i >= 0; i-- { | |
| t := entry.Trace[i] | |
| h.print(" ") | |
| h.print(symbolName(t)) | |
| if t.Position != nil { | |
| h.print(" @ ", symbolPath(t)) | |
| } | |
| h.print("\n") | |
| } | |
| } | |
| } | |
| } | |
| // symbolPath returns a user-friendly path to a symbol. | |
| func symbolPath(t *govulncheck.Frame) string { | |
| // Add module path prefix to symbol paths to be more | |
| // explicit to which module the symbols belong to. | |
| return t.Module + "/" + posToString(t.Position) | |
| } | |
| func (h *TextHandler) summary(c summaryCounters) { | |
| // print short summary of findings identified at the desired level of scan precision | |
| var vulnCount int | |
| h.print("Your code ", choose(h.scanLevel.WantSymbols(), "is", "may be"), " affected by ") | |
| switch h.scanLevel { | |
| case govulncheck.ScanLevelSymbol: | |
| vulnCount = c.VulnerabilitiesCalled | |
| case govulncheck.ScanLevelPackage: | |
| vulnCount = c.VulnerabilitiesImported | |
| case govulncheck.ScanLevelModule: | |
| vulnCount = c.VulnerabilitiesRequired | |
| } | |
| h.style(valueStyle, vulnCount) | |
| h.print(choose(vulnCount == 1, ` vulnerability`, ` vulnerabilities`)) | |
| if h.scanLevel.WantSymbols() { | |
| h.print(choose(c.ModulesCalled > 0 || c.StdlibCalled, ` from `, ``)) | |
| if c.ModulesCalled > 0 { | |
| h.style(valueStyle, c.ModulesCalled) | |
| h.print(choose(c.ModulesCalled == 1, ` module`, ` modules`)) | |
| } | |
| if c.StdlibCalled { | |
| if c.ModulesCalled != 0 { | |
| h.print(` and `) | |
| } | |
| h.print(`the Go standard library`) | |
| } | |
| } | |
| h.print(".\n") | |
| // print summary for vulnerabilities found at other levels of scan precision | |
| if other := h.summaryOtherVulns(c); other != "" { | |
| h.wrap("", other, 80) | |
| h.print("\n") | |
| } | |
| // print suggested flags for more/better info depending on scan level and if in verbose mode | |
| if sugg := h.summarySuggestion(); sugg != "" { | |
| h.wrap("", sugg, 80) | |
| h.print("\n") | |
| } | |
| } | |
| func (h *TextHandler) summaryOtherVulns(c summaryCounters) string { | |
| var summary strings.Builder | |
| if c.VulnerabilitiesRequired+c.VulnerabilitiesImported == 0 { | |
| summary.WriteString("This scan found no other vulnerabilities in ") | |
| if h.scanLevel.WantSymbols() { | |
| summary.WriteString("packages you import or ") | |
| } | |
| summary.WriteString("modules you require.") | |
| } else { | |
| summary.WriteString(choose(h.scanLevel.WantPackages(), "This scan also found ", "")) | |
| if h.scanLevel.WantSymbols() { | |
| summary.WriteString(fmt.Sprint(c.VulnerabilitiesImported)) | |
| summary.WriteString(choose(c.VulnerabilitiesImported == 1, ` vulnerability `, ` vulnerabilities `)) | |
| summary.WriteString("in packages you import and ") | |
| } | |
| if h.scanLevel.WantPackages() { | |
| summary.WriteString(fmt.Sprint(c.VulnerabilitiesRequired)) | |
| summary.WriteString(choose(c.VulnerabilitiesRequired == 1, ` vulnerability `, ` vulnerabilities `)) | |
| summary.WriteString("in modules you require") | |
| summary.WriteString(choose(h.scanLevel.WantSymbols(), ", but your code doesn't appear to call these vulnerabilities.", ".")) | |
| } | |
| } | |
| return summary.String() | |
| } | |
| func (h *TextHandler) summarySuggestion() string { | |
| var sugg strings.Builder | |
| switch h.scanLevel { | |
| case govulncheck.ScanLevelSymbol: | |
| if !h.showVerbose { | |
| sugg.WriteString("Use " + verboseMessage + ".") | |
| } | |
| case govulncheck.ScanLevelPackage: | |
| sugg.WriteString("Use " + symbolMessage) | |
| if !h.showVerbose { | |
| sugg.WriteString(" and " + verboseMessage) | |
| } | |
| sugg.WriteString(".") | |
| case govulncheck.ScanLevelModule: | |
| sugg.WriteString("Use " + symbolMessage + ".") | |
| } | |
| return sugg.String() | |
| } | |
| func (h *TextHandler) style(style style, values ...any) { | |
| if h.showColor { | |
| switch style { | |
| default: | |
| h.print(colorReset) | |
| case osvCalledStyle: | |
| h.print(colorBold, fgRed) | |
| case osvImportedStyle: | |
| h.print(colorBold, fgGreen) | |
| case detailsStyle: | |
| h.print(colorFaint) | |
| case sectionStyle: | |
| h.print(fgBlue) | |
| case keyStyle: | |
| h.print(colorFaint, fgYellow) | |
| case valueStyle: | |
| h.print(colorBold, fgCyan) | |
| } | |
| } | |
| h.print(values...) | |
| if h.showColor && len(values) > 0 { | |
| h.print(colorReset) | |
| } | |
| } | |
| func (h *TextHandler) print(values ...any) int { | |
| total, w := 0, 0 | |
| for _, v := range values { | |
| if h.err != nil { | |
| return total | |
| } | |
| // do we need to specialize for some types, like time? | |
| w, h.err = fmt.Fprint(h.w, v) | |
| total += w | |
| } | |
| return total | |
| } | |
| // wrap wraps s to fit in maxWidth by breaking it into lines at whitespace. If a | |
| // single word is longer than maxWidth, it is retained as its own line. | |
| func (h *TextHandler) wrap(indent string, s string, maxWidth int) { | |
| w := 0 | |
| for _, f := range strings.Fields(s) { | |
| if w > 0 && w+len(f)+1 > maxWidth { | |
| // line would be too long with this word | |
| h.print("\n") | |
| w = 0 | |
| } | |
| if w == 0 { | |
| // first field on line, indent | |
| w = h.print(indent) | |
| } else { | |
| // not first word, space separate | |
| w += h.print(" ") | |
| } | |
| // now write the word | |
| w += h.print(f) | |
| } | |
| } | |
| func choose[t any](b bool, yes, no t) t { | |
| if b { | |
| return yes | |
| } | |
| return no | |
| } | |
| func isStdFindings(findings []*findingSummary) bool { | |
| for _, f := range findings { | |
| if vulncheck.IsStdPackage(f.Trace[0].Package) || f.Trace[0].Module == internal.GoStdModulePath { | |
| return true | |
| } | |
| } | |
| return false | |
| } |