handler.go (11841B)
1 // Copyright 2023 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 sarif 6 7 import ( 8 "encoding/json" 9 "fmt" 10 "io" 11 "path/filepath" 12 "sort" 13 14 "golang.org/x/vuln/internal" 15 "golang.org/x/vuln/internal/govulncheck" 16 "golang.org/x/vuln/internal/osv" 17 "golang.org/x/vuln/internal/traces" 18 ) 19 20 // handler for sarif output. 21 type handler struct { 22 w io.Writer 23 cfg *govulncheck.Config 24 osvs map[string]*osv.Entry 25 // findings contains same-level findings for an 26 // OSV at the most precise level of granularity 27 // available. This means, for instance, that if 28 // an osv is indeed called, then all findings for 29 // the osv will have call stack info. 30 findings map[string][]*govulncheck.Finding 31 } 32 33 func NewHandler(w io.Writer) *handler { 34 return &handler{ 35 w: w, 36 osvs: make(map[string]*osv.Entry), 37 findings: make(map[string][]*govulncheck.Finding), 38 } 39 } 40 41 func (h *handler) Config(c *govulncheck.Config) error { 42 h.cfg = c 43 return nil 44 } 45 46 func (h *handler) Progress(p *govulncheck.Progress) error { 47 return nil // not needed by sarif 48 } 49 50 func (h *handler) SBOM(s *govulncheck.SBOM) error { 51 return nil // not needed by sarif 52 } 53 54 func (h *handler) OSV(e *osv.Entry) error { 55 h.osvs[e.ID] = e 56 return nil 57 } 58 59 // moreSpecific favors a call finding over a non-call 60 // finding and a package finding over a module finding. 61 func moreSpecific(f1, f2 *govulncheck.Finding) int { 62 if len(f1.Trace) > 1 && len(f2.Trace) > 1 { 63 // Both are call stack findings. 64 return 0 65 } 66 if len(f1.Trace) > 1 { 67 return -1 68 } 69 if len(f2.Trace) > 1 { 70 return 1 71 } 72 73 fr1, fr2 := f1.Trace[0], f2.Trace[0] 74 if fr1.Function != "" && fr2.Function == "" { 75 return -1 76 } 77 if fr1.Function == "" && fr2.Function != "" { 78 return 1 79 } 80 if fr1.Package != "" && fr2.Package == "" { 81 return -1 82 } 83 if fr1.Package == "" && fr2.Package != "" { 84 return -1 85 } 86 return 0 // findings always have module info 87 } 88 89 func (h *handler) Finding(f *govulncheck.Finding) error { 90 fs := h.findings[f.OSV] 91 if len(fs) == 0 { 92 fs = []*govulncheck.Finding{f} 93 } else { 94 if ms := moreSpecific(f, fs[0]); ms == -1 { 95 // The new finding is more specific, so we need 96 // to erase existing findings and add the new one. 97 fs = []*govulncheck.Finding{f} 98 } else if ms == 0 { 99 // The new finding is equal to an existing one and 100 // because of the invariant on h.findings, it is 101 // also equal to all existing ones. 102 fs = append(fs, f) 103 } 104 // Otherwise, the new finding is at a less precise level. 105 } 106 h.findings[f.OSV] = fs 107 return nil 108 } 109 110 // Flush is used to print out to w the sarif json output. 111 // This is needed as sarif is not streamed. 112 func (h *handler) Flush() error { 113 sLog := toSarif(h) 114 s, err := json.MarshalIndent(sLog, "", " ") 115 if err != nil { 116 return err 117 } 118 h.w.Write(s) 119 return nil 120 } 121 122 func toSarif(h *handler) Log { 123 cfg := h.cfg 124 r := Run{ 125 Tool: Tool{ 126 Driver: Driver{ 127 Name: cfg.ScannerName, 128 Version: cfg.ScannerVersion, 129 InformationURI: "https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck", 130 Properties: *cfg, 131 Rules: rules(h), 132 }, 133 }, 134 Results: results(h), 135 } 136 137 return Log{ 138 Version: "2.1.0", 139 Schema: "https://json.schemastore.org/sarif-2.1.0.json", 140 Runs: []Run{r}, 141 } 142 } 143 144 func rules(h *handler) []Rule { 145 rs := make([]Rule, 0, len(h.findings)) // must not be nil 146 for id := range h.findings { 147 osv := h.osvs[id] 148 // s is either summary if it exists, or details 149 // otherwise. Govulncheck text does the same. 150 s := osv.Summary 151 if s == "" { 152 s = osv.Details 153 } 154 rs = append(rs, Rule{ 155 ID: osv.ID, 156 ShortDescription: Description{Text: fmt.Sprintf("[%s] %s", osv.ID, s)}, 157 FullDescription: Description{Text: s}, 158 HelpURI: fmt.Sprintf("https://pkg.go.dev/vuln/%s", osv.ID), 159 Help: Description{Text: osv.Details}, 160 Properties: RuleTags{Tags: tags(osv)}, 161 }) 162 } 163 sort.SliceStable(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID }) 164 return rs 165 } 166 167 // tags returns an slice of zero or 168 // more aliases of o. 169 func tags(o *osv.Entry) []string { 170 if len(o.Aliases) > 0 { 171 return o.Aliases 172 } 173 return []string{} // must not be nil 174 } 175 176 func results(h *handler) []Result { 177 results := make([]Result, 0, len(h.findings)) // must not be nil 178 for osv, fs := range h.findings { 179 var locs []Location 180 if h.cfg.ScanMode != govulncheck.ScanModeBinary { 181 // Attach result to the go.mod file for source analysis. 182 // But there is no such place for binaries. 183 locs = []Location{{PhysicalLocation: PhysicalLocation{ 184 ArtifactLocation: ArtifactLocation{ 185 URI: "go.mod", 186 URIBaseID: SrcRootID, 187 }, 188 Region: Region{StartLine: 1}, // for now, point to the first line 189 }, 190 Message: Description{Text: fmt.Sprintf("Findings for vulnerability %s", osv)}, // not having a message here results in an invalid sarif 191 }} 192 } 193 194 res := Result{ 195 RuleID: osv, 196 Level: level(fs[0], h.cfg), 197 Message: Description{Text: resultMessage(fs, h.cfg)}, 198 Stacks: stacks(h, fs), 199 CodeFlows: codeFlows(h, fs), 200 Locations: locs, 201 } 202 results = append(results, res) 203 } 204 sort.SliceStable(results, func(i, j int) bool { return results[i].RuleID < results[j].RuleID }) // for deterministic output 205 return results 206 } 207 208 func resultMessage(findings []*govulncheck.Finding, cfg *govulncheck.Config) string { 209 // We can infer the findings' level by just looking at the 210 // top trace frame of any finding. 211 frame := findings[0].Trace[0] 212 uniqueElems := make(map[string]bool) 213 if frame.Function == "" && frame.Package == "" { // module level findings 214 for _, f := range findings { 215 uniqueElems[f.Trace[0].Module] = true 216 } 217 } else { // symbol and package level findings 218 for _, f := range findings { 219 uniqueElems[f.Trace[0].Package] = true 220 } 221 } 222 var elems []string 223 for e := range uniqueElems { 224 elems = append(elems, e) 225 } 226 sort.Strings(elems) 227 228 l := len(elems) 229 elemList := list(elems) 230 main, addition := "", "" 231 const runCallAnalysis = "Run the call-level analysis to understand whether your code actually calls the vulnerabilities." 232 switch { 233 case frame.Function != "": 234 main = fmt.Sprintf("calls vulnerable functions in %d package%s (%s).", l, choose("", "s", l == 1), elemList) 235 case frame.Package != "": 236 main = fmt.Sprintf("imports %d vulnerable package%s (%s)", l, choose("", "s", l == 1), elemList) 237 addition = choose(", but doesn’t appear to call any of the vulnerable symbols.", ". "+runCallAnalysis, cfg.ScanLevel.WantSymbols()) 238 default: 239 main = fmt.Sprintf("depends on %d vulnerable module%s (%s)", l, choose("", "s", l == 1), elemList) 240 informational := ", but doesn't appear to " + choose("call", "import", cfg.ScanLevel.WantSymbols()) + " any of the vulnerable symbols." 241 addition = choose(informational, ". "+runCallAnalysis, cfg.ScanLevel.WantPackages()) 242 } 243 244 return fmt.Sprintf("Your code %s%s", main, addition) 245 } 246 247 const ( 248 errorLevel = "error" 249 warningLevel = "warning" 250 informationalLevel = "note" 251 ) 252 253 func level(f *govulncheck.Finding, cfg *govulncheck.Config) string { 254 fr := f.Trace[0] 255 switch { 256 case cfg.ScanLevel.WantSymbols(): 257 if fr.Function != "" { 258 return errorLevel 259 } 260 if fr.Package != "" { 261 return warningLevel 262 } 263 return informationalLevel 264 case cfg.ScanLevel.WantPackages(): 265 if fr.Package != "" { 266 return errorLevel 267 } 268 return warningLevel 269 default: 270 return errorLevel 271 } 272 } 273 274 func stacks(h *handler, fs []*govulncheck.Finding) []Stack { 275 if fs[0].Trace[0].Function == "" { // not call level findings 276 return nil 277 } 278 279 var stacks []Stack 280 for _, f := range fs { 281 stacks = append(stacks, stack(h, f)) 282 } 283 // Sort stacks for deterministic output. We sort by message 284 // which is effectively sorting by full symbol name. The 285 // performance should not be an issue here. 286 sort.SliceStable(stacks, func(i, j int) bool { return stacks[i].Message.Text < stacks[j].Message.Text }) 287 return stacks 288 } 289 290 // stack transforms call stack in f to a sarif stack. 291 func stack(h *handler, f *govulncheck.Finding) Stack { 292 trace := f.Trace 293 top := trace[len(trace)-1] // belongs to top level module 294 295 frames := make([]Frame, 0, len(trace)) // must not be nil 296 for i := len(trace) - 1; i >= 0; i-- { // vulnerable symbol is at the top frame 297 frame := trace[i] 298 pos := govulncheck.Position{Line: 1, Column: 1} 299 if frame.Position != nil { 300 pos = *frame.Position 301 } 302 303 sf := Frame{ 304 Module: frame.Module + "@" + frame.Version, 305 Location: Location{Message: Description{Text: symbol(frame)}}, // show the (full) symbol name 306 } 307 file, base := fileURIInfo(pos.Filename, top.Module, frame.Module, frame.Version) 308 if h.cfg.ScanMode != govulncheck.ScanModeBinary { 309 sf.Location.PhysicalLocation = PhysicalLocation{ 310 ArtifactLocation: ArtifactLocation{ 311 URI: file, 312 URIBaseID: base, 313 }, 314 Region: Region{ 315 StartLine: pos.Line, 316 StartColumn: pos.Column, 317 }, 318 } 319 } 320 frames = append(frames, sf) 321 } 322 323 return Stack{ 324 Frames: frames, 325 Message: Description{Text: fmt.Sprintf("A call stack for vulnerable function %s", symbol(trace[0]))}, 326 } 327 } 328 329 func codeFlows(h *handler, fs []*govulncheck.Finding) []CodeFlow { 330 if fs[0].Trace[0].Function == "" { // not call level findings 331 return nil 332 } 333 334 // group call stacks per symbol. There should 335 // be one call stack currently per symbol, but 336 // this might change in the future. 337 m := make(map[govulncheck.Frame][]*govulncheck.Finding) 338 for _, f := range fs { 339 // fr.Position is currently the position 340 // of the definition of the vuln symbol 341 fr := *f.Trace[0] 342 m[fr] = append(m[fr], f) 343 } 344 345 var codeFlows []CodeFlow 346 for fr, fs := range m { 347 tfs := threadFlows(h, fs) 348 codeFlows = append(codeFlows, CodeFlow{ 349 ThreadFlows: tfs, 350 // TODO: should we instead show the message from govulncheck text output? 351 Message: Description{Text: fmt.Sprintf("A summarized code flow for vulnerable function %s", symbol(&fr))}, 352 }) 353 } 354 // Sort flows for deterministic output. We sort by message 355 // which is effectively sorting by full symbol name. The 356 // performance should not be an issue here. 357 sort.SliceStable(codeFlows, func(i, j int) bool { return codeFlows[i].Message.Text < codeFlows[j].Message.Text }) 358 return codeFlows 359 } 360 361 func threadFlows(h *handler, fs []*govulncheck.Finding) []ThreadFlow { 362 tfs := make([]ThreadFlow, 0, len(fs)) // must not be nil 363 for _, f := range fs { 364 trace := traces.Compact(f) 365 top := trace[len(trace)-1] // belongs to top level module 366 367 var tf []ThreadFlowLocation 368 for i := len(trace) - 1; i >= 0; i-- { // vulnerable symbol is at the top frame 369 // TODO: should we, similar to govulncheck text output, only 370 // mention three elements of the compact trace? 371 frame := trace[i] 372 pos := govulncheck.Position{Line: 1, Column: 1} 373 if frame.Position != nil { 374 pos = *frame.Position 375 } 376 377 tfl := ThreadFlowLocation{ 378 Module: frame.Module + "@" + frame.Version, 379 Location: Location{Message: Description{Text: symbol(frame)}}, // show the (full) symbol name 380 } 381 file, base := fileURIInfo(pos.Filename, top.Module, frame.Module, frame.Version) 382 if h.cfg.ScanMode != govulncheck.ScanModeBinary { 383 tfl.Location.PhysicalLocation = PhysicalLocation{ 384 ArtifactLocation: ArtifactLocation{ 385 URI: file, 386 URIBaseID: base, 387 }, 388 Region: Region{ 389 StartLine: pos.Line, 390 StartColumn: pos.Column, 391 }, 392 } 393 } 394 tf = append(tf, tfl) 395 } 396 tfs = append(tfs, ThreadFlow{Locations: tf}) 397 } 398 return tfs 399 } 400 401 func fileURIInfo(filename, top, module, version string) (string, string) { 402 if top == module { 403 return filename, SrcRootID 404 } 405 if module == internal.GoStdModulePath { 406 return filename, GoRootID 407 } 408 return filepath.ToSlash(filepath.Join(module+"@"+version, filename)), GoModCacheID 409 }