mirror of
https://github.com/SAP/jenkins-library.git
synced 2025-03-05 15:15:44 +02:00
452 lines
11 KiB
Go
452 lines
11 KiB
Go
package reporting
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/SAP/jenkins-library/pkg/orchestrator"
|
|
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// IssueDetail represents any content that can be transformed into the body of a GitHub issue
|
|
type IssueDetail interface {
|
|
Title() string
|
|
ToMarkdown() ([]byte, error)
|
|
ToTxt() string
|
|
}
|
|
|
|
// VulnerabilityReport represents metadata for a report on a vulnerability
|
|
type VulnerabilityReport struct {
|
|
ArtifactID string
|
|
Branch string
|
|
CommitID string
|
|
Description string
|
|
DirectDependency string
|
|
Footer string
|
|
Group string
|
|
PackageURL string
|
|
PipelineName string
|
|
PipelineLink string
|
|
PublishDate string
|
|
Resolution string
|
|
Score float64
|
|
Severity string
|
|
Version string
|
|
VulnerabilityLink string
|
|
VulnerabilityName string
|
|
}
|
|
|
|
const vulnerabilityMdTemplate string = `# {{title .Severity }} ({{ .Score }}) Vulnerability {{ .VulnerabilityName }} - {{ .ArtifactID }}
|
|
|
|
**Vulnerability link:** [{{ .VulnerabilityLink }}]({{ .VulnerabilityLink }})
|
|
|
|
## Fix
|
|
|
|
**{{ .Resolution }}**
|
|
|
|
## Context
|
|
|
|
{{if .PipelineLink -}}
|
|
### Pipeline
|
|
|
|
Pipeline run: [{{ .PipelineName }}]({{ .PipelineLink }})
|
|
{{- end}}
|
|
|
|
### Detected in
|
|
|
|
{{if .Branch}}**Branch:** {{ .Branch }}{{- end}}
|
|
{{if .CommitID}}**CommitId:** {{ .CommitID }}{{- end}}
|
|
{{if .DirectDependency}}**Dependency:** {{if (eq .DirectDependency "true")}}direct{{ else }}indirect{{ end }}{{- end}}
|
|
{{if .ArtifactID}}**ArtifactId:** {{ .ArtifactID }}{{- end}}
|
|
{{if .Group}}**Group:** {{ .Group }}{{- end}}
|
|
{{if .Version}}**Version:** {{ .Version }}{{- end}}
|
|
{{if .PackageURL}}**Package URL:** {{ .PackageURL }}{{- end}}
|
|
{{if .PublishDate}}**Publishing date:** {{.PublishDate }}{{- end}}
|
|
|
|
## Description
|
|
|
|
{{ .Description }}
|
|
|
|
---
|
|
|
|
{{.Footer}}
|
|
`
|
|
|
|
// ToMarkdown creates a vulnerability in markdown format which can be used in GitHub issues
|
|
func (v *VulnerabilityReport) ToMarkdown() ([]byte, error) {
|
|
funcMap := template.FuncMap{
|
|
"date": func(t time.Time) string {
|
|
return t.Format("2006-01-02")
|
|
},
|
|
"title": func(s string) string {
|
|
caser := cases.Title(language.AmericanEnglish)
|
|
return caser.String(s)
|
|
},
|
|
}
|
|
|
|
// only fill with orchestrator information if orchestrator can be identified properly
|
|
if provider, err := orchestrator.NewOrchestratorSpecificConfigProvider(); err == nil {
|
|
// only add information if not yet provided
|
|
if len(v.CommitID) == 0 {
|
|
v.CommitID = provider.GetCommit()
|
|
}
|
|
if len(v.PipelineLink) == 0 {
|
|
v.PipelineLink = provider.GetJobURL()
|
|
v.PipelineName = provider.GetJobName()
|
|
}
|
|
}
|
|
|
|
md := []byte{}
|
|
tmpl, err := template.New("report").Funcs(funcMap).Parse(vulnerabilityMdTemplate)
|
|
if err != nil {
|
|
return md, fmt.Errorf("failed to create markdown issue template: %w", err)
|
|
}
|
|
buf := new(bytes.Buffer)
|
|
err = tmpl.Execute(buf, v)
|
|
if err != nil {
|
|
return md, fmt.Errorf("failed to execute markdown issue template: %w", err)
|
|
}
|
|
md = buf.Bytes()
|
|
return md, nil
|
|
}
|
|
|
|
// ScanReport defines the elements of a scan report used by various scan steps
|
|
type ScanReport struct {
|
|
StepName string `json:"stepName"`
|
|
ReportTitle string `json:"title"`
|
|
Subheaders []Subheader `json:"subheaders"`
|
|
Overview []OverviewRow `json:"overview"`
|
|
FurtherInfo string `json:"furtherInfo"`
|
|
ReportTime time.Time `json:"reportTime"`
|
|
DetailTable ScanDetailTable `json:"detailTable"`
|
|
SuccessfulScan bool `json:"successfulScan"`
|
|
}
|
|
|
|
// ScanDetailTable defines a table containing scan result details
|
|
type ScanDetailTable struct {
|
|
Headers []string `json:"headers"`
|
|
Rows []ScanRow `json:"rows"`
|
|
WithCounter bool `json:"withCounter"`
|
|
CounterHeader string `json:"counterHeader"`
|
|
NoRowsMessage string `json:"noRowsMessage"`
|
|
}
|
|
|
|
// ScanRow defines one row of a scan result table
|
|
type ScanRow struct {
|
|
Columns []ScanCell `json:"columns"`
|
|
}
|
|
|
|
// AddColumn adds a column to a dedicated ScanRow
|
|
func (s *ScanRow) AddColumn(content interface{}, style ColumnStyle) {
|
|
if s.Columns == nil {
|
|
s.Columns = []ScanCell{}
|
|
}
|
|
s.Columns = append(s.Columns, ScanCell{Content: fmt.Sprint(content), Style: style})
|
|
}
|
|
|
|
// ScanCell defines one column of a scan result table
|
|
type ScanCell struct {
|
|
Content string `json:"content"`
|
|
Style ColumnStyle `json:"style"`
|
|
}
|
|
|
|
// ColumnStyle defines style for a specific column
|
|
type ColumnStyle int
|
|
|
|
// enum for style types
|
|
const (
|
|
Green = iota + 1
|
|
Yellow
|
|
Red
|
|
Grey
|
|
Black
|
|
)
|
|
|
|
func (c ColumnStyle) String() string {
|
|
return [...]string{"", "green-cell", "yellow-cell", "red-cell", "grey-cell", "black-cell"}[c]
|
|
}
|
|
|
|
// OverviewRow defines a row in the report's overview section
|
|
// it can consist of a description and some details where the details can have a style attached
|
|
type OverviewRow struct {
|
|
Description string `json:"description"`
|
|
Details string `json:"details,omitempty"`
|
|
Style ColumnStyle `json:"style,omitempty"`
|
|
}
|
|
|
|
// Subheader defines a dedicated sub header in a report
|
|
type Subheader struct {
|
|
Description string `json:"text"`
|
|
Details string `json:"details,omitempty"`
|
|
}
|
|
|
|
// AddSubHeader adds a sub header to the report containing of a text/title plus optional details
|
|
func (s *ScanReport) AddSubHeader(header, details string) {
|
|
s.Subheaders = append(s.Subheaders, Subheader{Description: header, Details: details})
|
|
}
|
|
|
|
// StepReportDirectory specifies the default directory for markdown reports which can later be collected by step pipelineCreateSummary
|
|
const StepReportDirectory = ".pipeline/stepReports"
|
|
|
|
// ToJSON returns the report in JSON format
|
|
func (s *ScanReport) ToJSON() ([]byte, error) {
|
|
return json.Marshal(s)
|
|
}
|
|
|
|
// ToTxt up to now returns the report in JSON format
|
|
func (s ScanReport) ToTxt() string {
|
|
txt, _ := s.ToJSON()
|
|
return string(txt)
|
|
}
|
|
|
|
const reportHTMLTemplate = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{.Title}}</title>
|
|
<style type="text/css">
|
|
body {
|
|
font-family: Arial, Verdana;
|
|
}
|
|
table {
|
|
border-collapse: collapse;
|
|
}
|
|
div.code {
|
|
font-family: "Courier New", "Lucida Console";
|
|
}
|
|
th {
|
|
border-top: 1px solid #ddd;
|
|
}
|
|
th, td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #ddd;
|
|
border-right: 1px solid #ddd;
|
|
}
|
|
tr:nth-child(even) {
|
|
background-color: #f2f2f2;
|
|
}
|
|
.bold {
|
|
font-weight: bold;
|
|
}
|
|
.green{
|
|
color: olivedrab;
|
|
}
|
|
.red{
|
|
color: orangered;
|
|
}
|
|
.nobullets {
|
|
list-style-type:none;
|
|
padding-left: 0;
|
|
padding-bottom: 0;
|
|
margin: 0;
|
|
}
|
|
.green-cell {
|
|
background-color: #e1f5a9;
|
|
padding: 5px
|
|
}
|
|
.yellow-cell {
|
|
background-color: #ffff99;
|
|
padding: 5px
|
|
}
|
|
.red-cell {
|
|
background-color: #ffe5e5;
|
|
padding: 5px
|
|
}
|
|
.grey-cell{
|
|
background-color: rgba(212, 212, 212, 0.7);
|
|
padding: 5px;
|
|
}
|
|
.black-cell{
|
|
background-color: rgba(0, 0, 0, 0.75);
|
|
padding: 5px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{{.Title}}</h1>
|
|
<h2>
|
|
<span>
|
|
{{range $s := .Subheaders}}
|
|
{{- $s.Description}}: {{$s.Details}}<br />
|
|
{{end -}}
|
|
</span>
|
|
</h2>
|
|
<div>
|
|
<h3>
|
|
{{range $o := .Overview}}
|
|
{{- drawOverviewRow $o}}<br />
|
|
{{end -}}
|
|
</h3>
|
|
<span>{{.FurtherInfo}}</span>
|
|
</div>
|
|
<p>Snapshot taken: {{reportTime .ReportTime}}</p>
|
|
<table>
|
|
<tr>
|
|
{{if .DetailTable.WithCounter}}<th>{{.DetailTable.CounterHeader}}</th>{{end}}
|
|
{{- range $h := .DetailTable.Headers}}
|
|
<th>{{$h}}</th>
|
|
{{- end}}
|
|
</tr>
|
|
{{range $i, $r := .DetailTable.Rows}}
|
|
<tr>
|
|
{{if $.DetailTable.WithCounter}}<td>{{inc $i}}</td>{{end}}
|
|
{{- range $c := $r.Columns}}
|
|
{{drawCell $c}}
|
|
{{- end}}
|
|
</tr>
|
|
{{else}}
|
|
<tr><td colspan="{{columnCount .DetailTable}}">{{.DetailTable.NoRowsMessage}}</td></tr>
|
|
{{- end}}
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
// ToHTML creates a HTML version of the report
|
|
func (s *ScanReport) ToHTML() ([]byte, error) {
|
|
funcMap := template.FuncMap{
|
|
"inc": func(i int) int {
|
|
return i + 1
|
|
},
|
|
"reportTime": func(currentTime time.Time) string {
|
|
return currentTime.Format("Jan 02, 2006 - 15:04:05 MST")
|
|
},
|
|
"columnCount": tableColumnCount,
|
|
"drawCell": drawCell,
|
|
"drawOverviewRow": drawOverviewRow,
|
|
}
|
|
report := []byte{}
|
|
tmpl, err := template.New("report").Funcs(funcMap).Parse(reportHTMLTemplate)
|
|
if err != nil {
|
|
return report, errors.Wrap(err, "failed to create HTML report template")
|
|
}
|
|
buf := new(bytes.Buffer)
|
|
err = tmpl.Execute(buf, s)
|
|
if err != nil {
|
|
return report, errors.Wrap(err, "failed to execute HTML report template")
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
const reportMdTemplate = `## {{if .SuccessfulScan}}:white_check_mark:{{else}}:x:{{end}} {{.Title}}
|
|
|
|
<table>
|
|
{{range $s := .Subheaders -}}
|
|
<tr><td><b>{{- $s.Description}}:</b></td><td>{{$s.Details}}</td></tr>
|
|
{{- end}}
|
|
|
|
{{range $o := .Overview -}}
|
|
{{drawOverviewRow $o}}
|
|
{{- end}}
|
|
</table>
|
|
|
|
{{.FurtherInfo}}
|
|
|
|
Snapshot taken: <i>{{reportTime .ReportTime}}</i>
|
|
|
|
{{if shouldDrawTable .DetailTable -}}
|
|
<details><summary><i>{{.Title}} details:</i></summary>
|
|
<p>
|
|
|
|
<table>
|
|
<tr>
|
|
{{if .DetailTable.WithCounter}}<th>{{.DetailTable.CounterHeader}}</th>{{end}}
|
|
{{- range $h := .DetailTable.Headers}}
|
|
<th>{{$h}}</th>
|
|
{{- end}}
|
|
</tr>
|
|
{{range $i, $r := .DetailTable.Rows}}
|
|
<tr>
|
|
{{if $.DetailTable.WithCounter}}<td>{{inc $i}}</td>{{end}}
|
|
{{- range $c := $r.Columns}}
|
|
{{drawCell $c}}
|
|
{{- end}}
|
|
</tr>
|
|
{{else}}
|
|
<tr><td colspan="{{columnCount .DetailTable}}">{{.DetailTable.NoRowsMessage}}</td></tr>
|
|
{{- end}}
|
|
</table>
|
|
</p>
|
|
</details>
|
|
{{ end }}
|
|
|
|
`
|
|
|
|
// Title returns the title of the report
|
|
func (s ScanReport) Title() string {
|
|
return s.ReportTitle
|
|
}
|
|
|
|
// ToMarkdown creates a markdown version of the report content
|
|
func (s ScanReport) ToMarkdown() ([]byte, error) {
|
|
funcMap := template.FuncMap{
|
|
"columnCount": tableColumnCount,
|
|
"drawCell": drawCell,
|
|
"shouldDrawTable": shouldDrawTable,
|
|
"inc": func(i int) int {
|
|
return i + 1
|
|
},
|
|
"reportTime": func(currentTime time.Time) string {
|
|
return currentTime.Format("Jan 02, 2006 - 15:04:05 MST")
|
|
},
|
|
"drawOverviewRow": drawOverviewRowMarkdown,
|
|
}
|
|
report := []byte{}
|
|
tmpl, err := template.New("report").Funcs(funcMap).Parse(reportMdTemplate)
|
|
if err != nil {
|
|
return report, errors.Wrap(err, "failed to create Markdown report template")
|
|
}
|
|
buf := new(bytes.Buffer)
|
|
err = tmpl.Execute(buf, s)
|
|
if err != nil {
|
|
return report, errors.Wrap(err, "failed to execute Markdown report template")
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func tableColumnCount(scanDetails ScanDetailTable) int {
|
|
colCount := len(scanDetails.Headers)
|
|
if scanDetails.WithCounter {
|
|
colCount++
|
|
}
|
|
return colCount
|
|
}
|
|
|
|
func drawCell(cell ScanCell) string {
|
|
if cell.Style > 0 {
|
|
return fmt.Sprintf(`<td class="%v">%v</td>`, cell.Style, cell.Content)
|
|
}
|
|
return fmt.Sprintf(`<td>%v</td>`, cell.Content)
|
|
}
|
|
|
|
func shouldDrawTable(table ScanDetailTable) bool {
|
|
if len(table.Headers) > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func drawOverviewRow(row OverviewRow) string {
|
|
// so far accept only accept max. two columns for overview table: description and content
|
|
if len(row.Details) == 0 {
|
|
return row.Description
|
|
}
|
|
// ToDo: allow styling of details
|
|
return fmt.Sprintf("%v: %v", row.Description, row.Details)
|
|
}
|
|
|
|
func drawOverviewRowMarkdown(row OverviewRow) string {
|
|
// so far accept only accept max. two columns for overview table: description and content
|
|
if len(row.Details) == 0 {
|
|
return row.Description
|
|
}
|
|
// ToDo: allow styling of details
|
|
return fmt.Sprintf("<tr><td>%v:</td><td>%v</td></tr>", row.Description, row.Details)
|
|
}
|