sarif.go raw

   1  package lintcmd
   2  
   3  // Notes on GitHub-specific restrictions:
   4  //
   5  // Result.Message needs to either have ID or Text set. Markdown
   6  // gets ignored. Text isn't treated verbatim however: Markdown
   7  // formatting gets stripped, except for links.
   8  //
   9  // GitHub does not display RelatedLocations. The only way to make
  10  // use of them is to link to them (via their ID) in the
  11  // Result.Message. And even then, it will only show the referred
  12  // line of code, not the message. We can duplicate the messages in
  13  // the Result.Message, but we can't even indent them, because
  14  // leading whitespace gets stripped.
  15  //
  16  // GitHub does use the Markdown version of rule help, but it
  17  // renders it the way it renders comments on issues – that is, it
  18  // turns line breaks into hard line breaks, even though it
  19  // shouldn't.
  20  //
  21  // GitHub doesn't make use of the tool's URI or version, nor of
  22  // the help URIs of rules.
  23  //
  24  // There does not seem to be a way of using SARIF for "normal" CI,
  25  // without results showing up as code scanning alerts. Also, a
  26  // SARIF file containing only warnings, no errors, will not fail
  27  // CI by default, but this is configurable.
  28  // GitHub does display some parts of SARIF results in PRs, but
  29  // most of the useful parts of SARIF, such as help text of rules,
  30  // is only accessible via the code scanning alerts, which are only
  31  // accessible by users with write permissions.
  32  //
  33  // Result.Suppressions is being ignored.
  34  //
  35  //
  36  // Notes on other tools
  37  //
  38  // VS Code Sarif viewer
  39  //
  40  // The Sarif viewer in VS Code displays the full message in the
  41  // tabular view, removing newlines. That makes our multi-line
  42  // messages (which we use as a workaround for missing related
  43  // information) very ugly.
  44  //
  45  // Much like GitHub, the Sarif viewer does not make related
  46  // information visible unless we explicitly refer to it in the
  47  // message.
  48  //
  49  // Suggested fixes are not exposed in any way.
  50  //
  51  // It only shows the shortDescription or fullDescription of a
  52  // rule, not its help. We can't put the help in fullDescription,
  53  // because the fullDescription isn't meant to be that long. For
  54  // example, GitHub displays it in a single line, under the
  55  // shortDescription.
  56  //
  57  // VS Code can filter based on Result.Suppressions, but it doesn't
  58  // display our suppression message. Also, by default, suppressed
  59  // results get shown, and the column indicating that a result is
  60  // suppressed is hidden, which makes for a confusing experience.
  61  //
  62  // When a rule has only an ID, no name, VS Code displays a
  63  // prominent dash in place of the name. When the name and ID are
  64  // identical, it prints both. However, we can't make them
  65  // identical, as SARIF requires that either the ID and name are
  66  // different, or that the name is omitted.
  67  
  68  // FIXME(dh): we're currently reporting column information using UTF-8
  69  // byte offsets, not using Unicode code points or UTF-16, which are
  70  // the only two ways allowed by SARIF.
  71  
  72  // TODO(dh) set properties.tags – we can use different tags for the
  73  // staticcheck, simple, stylecheck and unused checks, so users can
  74  // filter their results
  75  
  76  import (
  77  	"encoding/json"
  78  	"fmt"
  79  	"net/url"
  80  	"os"
  81  	"path/filepath"
  82  	"regexp"
  83  	"strings"
  84  
  85  	"honnef.co/go/tools/analysis/lint"
  86  	"honnef.co/go/tools/sarif"
  87  )
  88  
  89  type sarifFormatter struct {
  90  	driverName    string
  91  	driverVersion string
  92  	driverWebsite string
  93  }
  94  
  95  func sarifLevel(severity lint.Severity) string {
  96  	switch severity {
  97  	case lint.SeverityNone:
  98  		// no configured severity, default to warning
  99  		return "warning"
 100  	case lint.SeverityError:
 101  		return "error"
 102  	case lint.SeverityDeprecated:
 103  		return "warning"
 104  	case lint.SeverityWarning:
 105  		return "warning"
 106  	case lint.SeverityInfo:
 107  		return "note"
 108  	case lint.SeverityHint:
 109  		return "note"
 110  	default:
 111  		// unreachable
 112  		return "none"
 113  	}
 114  }
 115  
 116  func encodePath(path string) string {
 117  	return (&url.URL{Path: path}).EscapedPath()
 118  }
 119  
 120  func sarifURI(path string) string {
 121  	u := url.URL{
 122  		Scheme: "file",
 123  		Path:   path,
 124  	}
 125  	return u.String()
 126  }
 127  
 128  func sarifArtifactLocation(name string) sarif.ArtifactLocation {
 129  	// Ideally we use relative paths so that GitHub can resolve them
 130  	name = shortPath(name)
 131  	if filepath.IsAbs(name) {
 132  		return sarif.ArtifactLocation{
 133  			URI: sarifURI(name),
 134  		}
 135  	} else {
 136  		return sarif.ArtifactLocation{
 137  			URI:       encodePath(name),
 138  			URIBaseID: "%SRCROOT%", // This is specific to GitHub,
 139  		}
 140  	}
 141  }
 142  
 143  func sarifFormatText(s string) string {
 144  	// GitHub doesn't ignore line breaks, even though it should, so we remove them.
 145  
 146  	var out strings.Builder
 147  	lines := strings.Split(s, "\n")
 148  	for i, line := range lines[:len(lines)-1] {
 149  		out.WriteString(line)
 150  		if line == "" {
 151  			out.WriteString("\n")
 152  		} else {
 153  			nextLine := lines[i+1]
 154  			if nextLine == "" || strings.HasPrefix(line, "> ") || strings.HasPrefix(line, "    ") {
 155  				out.WriteString("\n")
 156  			} else {
 157  				out.WriteString(" ")
 158  			}
 159  		}
 160  	}
 161  	out.WriteString(lines[len(lines)-1])
 162  	return convertCodeBlocks(out.String())
 163  }
 164  
 165  func moreCodeFollows(lines []string) bool {
 166  	for _, line := range lines {
 167  		if line == "" {
 168  			continue
 169  		}
 170  		if strings.HasPrefix(line, "    ") {
 171  			return true
 172  		} else {
 173  			return false
 174  		}
 175  	}
 176  	return false
 177  }
 178  
 179  var alpha = regexp.MustCompile(`^[a-zA-Z ]+$`)
 180  
 181  func convertCodeBlocks(text string) string {
 182  	var buf strings.Builder
 183  	lines := strings.Split(text, "\n")
 184  
 185  	inCode := false
 186  	empties := 0
 187  	for i, line := range lines {
 188  		if inCode {
 189  			if !moreCodeFollows(lines[i:]) {
 190  				if inCode {
 191  					fmt.Fprintln(&buf, "```")
 192  					inCode = false
 193  				}
 194  			}
 195  		}
 196  
 197  		prevEmpties := empties
 198  		if line == "" && !inCode {
 199  			empties++
 200  		} else {
 201  			empties = 0
 202  		}
 203  
 204  		if line == "" {
 205  			fmt.Fprintln(&buf)
 206  			continue
 207  		}
 208  
 209  		if strings.HasPrefix(line, "    ") {
 210  			line = line[4:]
 211  			if !inCode {
 212  				fmt.Fprintln(&buf, "```go")
 213  				inCode = true
 214  			}
 215  		}
 216  
 217  		onlyAlpha := alpha.MatchString(line)
 218  		out := line
 219  		if !inCode && prevEmpties >= 2 && onlyAlpha {
 220  			fmt.Fprintf(&buf, "## %s\n", out)
 221  		} else {
 222  			fmt.Fprint(&buf, out)
 223  			fmt.Fprintln(&buf)
 224  		}
 225  	}
 226  	if inCode {
 227  		fmt.Fprintln(&buf, "```")
 228  	}
 229  
 230  	return buf.String()
 231  }
 232  
 233  func (o *sarifFormatter) Format(checks []*lint.Analyzer, diagnostics []diagnostic) {
 234  	// TODO(dh): some diagnostics shouldn't be reported as results. For example, when the user specifies a package on the command line that doesn't exist.
 235  
 236  	cwd, _ := os.Getwd()
 237  	run := sarif.Run{
 238  		Tool: sarif.Tool{
 239  			Driver: sarif.ToolComponent{
 240  				Name:           o.driverName,
 241  				Version:        o.driverVersion,
 242  				InformationURI: o.driverWebsite,
 243  			},
 244  		},
 245  		Invocations: []sarif.Invocation{{
 246  			Arguments: os.Args[1:],
 247  			WorkingDirectory: sarif.ArtifactLocation{
 248  				URI: sarifURI(cwd),
 249  			},
 250  			ExecutionSuccessful: true,
 251  		}},
 252  	}
 253  	for _, c := range checks {
 254  		doc := c.Doc.Compile()
 255  		run.Tool.Driver.Rules = append(run.Tool.Driver.Rules,
 256  			sarif.ReportingDescriptor{
 257  				// We don't set Name, as Name and ID mustn't be identical.
 258  				ID: c.Analyzer.Name,
 259  				ShortDescription: sarif.Message{
 260  					Text:     doc.Title,
 261  					Markdown: doc.TitleMarkdown,
 262  				},
 263  				HelpURI: "https://staticcheck.dev/docs/checks#" + c.Analyzer.Name,
 264  				// We use our markdown as the plain text version, too. We
 265  				// use very little markdown, primarily quotations,
 266  				// indented code blocks and backticks. All of these are
 267  				// fine as plain text, too.
 268  				Help: sarif.Message{
 269  					Text:     sarifFormatText(doc.Format(false)),
 270  					Markdown: sarifFormatText(doc.FormatMarkdown(false)),
 271  				},
 272  				DefaultConfiguration: sarif.ReportingConfiguration{
 273  					// TODO(dh): we could figure out which checks were disabled globally
 274  					Enabled: true,
 275  					Level:   sarifLevel(doc.Severity),
 276  				},
 277  			})
 278  	}
 279  
 280  	for _, p := range diagnostics {
 281  		r := sarif.Result{
 282  			RuleID: p.Category,
 283  			Kind:   sarif.Fail,
 284  			Message: sarif.Message{
 285  				Text: p.Message,
 286  			},
 287  		}
 288  		r.Locations = []sarif.Location{{
 289  			PhysicalLocation: sarif.PhysicalLocation{
 290  				ArtifactLocation: sarifArtifactLocation(p.Position.Filename),
 291  				Region: sarif.Region{
 292  					StartLine:   p.Position.Line,
 293  					StartColumn: p.Position.Column,
 294  					EndLine:     p.End.Line,
 295  					EndColumn:   p.End.Column,
 296  				},
 297  			},
 298  		}}
 299  		for _, fix := range p.SuggestedFixes {
 300  			sfix := sarif.Fix{
 301  				Description: sarif.Message{
 302  					Text: fix.Message,
 303  				},
 304  			}
 305  			// file name -> replacements
 306  			changes := map[string][]sarif.Replacement{}
 307  			for _, edit := range fix.TextEdits {
 308  				changes[edit.Position.Filename] = append(changes[edit.Position.Filename], sarif.Replacement{
 309  					DeletedRegion: sarif.Region{
 310  						StartLine:   edit.Position.Line,
 311  						StartColumn: edit.Position.Column,
 312  						EndLine:     edit.End.Line,
 313  						EndColumn:   edit.End.Column,
 314  					},
 315  					InsertedContent: sarif.ArtifactContent{
 316  						Text: string(edit.NewText),
 317  					},
 318  				})
 319  			}
 320  			for path, replacements := range changes {
 321  				sfix.ArtifactChanges = append(sfix.ArtifactChanges, sarif.ArtifactChange{
 322  					ArtifactLocation: sarifArtifactLocation(path),
 323  					Replacements:     replacements,
 324  				})
 325  			}
 326  			r.Fixes = append(r.Fixes, sfix)
 327  		}
 328  		for i, related := range p.Related {
 329  			r.Message.Text += fmt.Sprintf("\n\t[%s](%d)", related.Message, i+1)
 330  
 331  			r.RelatedLocations = append(r.RelatedLocations,
 332  				sarif.Location{
 333  					ID: i + 1,
 334  					Message: &sarif.Message{
 335  						Text: related.Message,
 336  					},
 337  					PhysicalLocation: sarif.PhysicalLocation{
 338  						ArtifactLocation: sarifArtifactLocation(related.Position.Filename),
 339  						Region: sarif.Region{
 340  							StartLine:   related.Position.Line,
 341  							StartColumn: related.Position.Column,
 342  							EndLine:     related.End.Line,
 343  							EndColumn:   related.End.Column,
 344  						},
 345  					},
 346  				})
 347  		}
 348  
 349  		if p.Severity == severityIgnored {
 350  			// Note that GitHub does not support suppressions, which is why Staticcheck still requires the -show-ignored flag to be set for us to emit ignored diagnostics.
 351  
 352  			r.Suppressions = []sarif.Suppression{{
 353  				Kind: "inSource",
 354  				// TODO(dh): populate the Justification field
 355  			}}
 356  		} else {
 357  			// We want an empty slice, not nil. SARIF differentiates
 358  			// between the two. An empty slice means that the diagnostic
 359  			// wasn't suppressed, while nil means that we don't have the
 360  			// information available.
 361  			r.Suppressions = []sarif.Suppression{}
 362  		}
 363  		run.Results = append(run.Results, r)
 364  	}
 365  
 366  	json.NewEncoder(os.Stdout).Encode(sarif.Log{
 367  		Version: sarif.Version,
 368  		Schema:  sarif.Schema,
 369  		Runs:    []sarif.Run{run},
 370  	})
 371  }
 372