sarif.go 10 KB

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