github.com/seilagamo/poc-lava-release@v0.3.3-rc3/internal/report/report.go (about) 1 // Copyright 2023 Adevinta 2 3 // Package report renders Lava reports in different formats using the 4 // results returned by the Vulcan checks. 5 package report 6 7 import ( 8 "cmp" 9 "errors" 10 "fmt" 11 "io" 12 "os" 13 "regexp" 14 "slices" 15 16 report "github.com/adevinta/vulcan-report" 17 18 "github.com/seilagamo/poc-lava-release/internal/config" 19 "github.com/seilagamo/poc-lava-release/internal/engine" 20 "github.com/seilagamo/poc-lava-release/internal/metrics" 21 ) 22 23 // Writer represents a Lava report writer. 24 type Writer struct { 25 prn printer 26 w io.WriteCloser 27 isStdout bool 28 minSeverity config.Severity 29 exclusions []config.Exclusion 30 } 31 32 // NewWriter creates a new instance of a report writer. 33 func NewWriter(cfg config.ReportConfig) (Writer, error) { 34 var prn printer 35 switch cfg.Format { 36 case config.OutputFormatHuman: 37 prn = humanPrinter{} 38 case config.OutputFormatJSON: 39 prn = jsonPrinter{} 40 default: 41 return Writer{}, errors.New("unsupported output format") 42 } 43 44 w := os.Stdout 45 isStdout := true 46 if cfg.OutputFile != "" { 47 f, err := os.Create(cfg.OutputFile) 48 if err != nil { 49 return Writer{}, fmt.Errorf("create file: %w", err) 50 } 51 w = f 52 isStdout = false 53 } 54 55 return Writer{ 56 prn: prn, 57 w: w, 58 isStdout: isStdout, 59 minSeverity: cfg.Severity, 60 exclusions: cfg.Exclusions, 61 }, nil 62 } 63 64 // Write renders the provided [engine.Report]. The returned exit code 65 // is calculated by evaluating the report with the [config.ReportConfig] 66 // passed to [NewWriter]. If the returned error is not nil, the exit code 67 // will be zero and should be ignored. 68 func (writer Writer) Write(er engine.Report) (ExitCode, error) { 69 vulns, err := writer.parseReport(er) 70 if err != nil { 71 return 0, fmt.Errorf("parse report: %w", err) 72 } 73 74 sum, err := mkSummary(vulns) 75 if err != nil { 76 return 0, fmt.Errorf("calculate summary: %w", err) 77 } 78 79 metrics.Collect("excluded_vulnerability_count", sum.excluded) 80 metrics.Collect("vulnerability_count", sum.count) 81 82 exitCode := writer.calculateExitCode(sum) 83 fvulns := writer.filterVulns(vulns) 84 if err = writer.prn.Print(writer.w, fvulns, sum); err != nil { 85 return exitCode, fmt.Errorf("print report: %w", err) 86 } 87 88 return exitCode, nil 89 } 90 91 // Close closes the [Writer]. 92 func (writer Writer) Close() error { 93 if !writer.isStdout { 94 if err := writer.w.Close(); err != nil { 95 return fmt.Errorf("close writer: %w", err) 96 } 97 } 98 return nil 99 } 100 101 // parseReport converts the provided [engine.Report] into a list of 102 // vulnerabilities. It calculates the severity of each vulnerability 103 // based on its score and determines if the vulnerability is excluded 104 // according to the [Writer] configuration. 105 func (writer Writer) parseReport(er engine.Report) ([]vulnerability, error) { 106 var vulns []vulnerability 107 for _, r := range er { 108 for _, vuln := range r.ResultData.Vulnerabilities { 109 severity := scoreToSeverity(vuln.Score) 110 excluded, err := writer.isExcluded(vuln, r.Target) 111 if err != nil { 112 return nil, fmt.Errorf("vulnerability exlusion: %w", err) 113 } 114 v := vulnerability{ 115 CheckData: r.CheckData, 116 Vulnerability: vuln, 117 Severity: severity, 118 excluded: excluded, 119 } 120 vulns = append(vulns, v) 121 } 122 } 123 return vulns, nil 124 } 125 126 // isExcluded returns whether the provided [report.Vulnerability] is 127 // excluded based on the [Writer] configuration and the affected target. 128 func (writer Writer) isExcluded(v report.Vulnerability, target string) (bool, error) { 129 for _, excl := range writer.exclusions { 130 if excl.Fingerprint != "" && v.Fingerprint != excl.Fingerprint { 131 continue 132 } 133 134 if excl.Summary != "" { 135 matched, err := regexp.MatchString(excl.Summary, v.Summary) 136 if err != nil { 137 return false, fmt.Errorf("match string: %w", err) 138 } 139 if !matched { 140 continue 141 } 142 } 143 144 if excl.Target != "" { 145 matched, err := regexp.MatchString(excl.Target, target) 146 if err != nil { 147 return false, fmt.Errorf("match string: %w", err) 148 } 149 if !matched { 150 continue 151 } 152 } 153 154 if excl.Resource != "" { 155 matchedResource, err := regexp.MatchString(excl.Resource, v.AffectedResource) 156 if err != nil { 157 return false, fmt.Errorf("match string: %w", err) 158 } 159 matchedResourceString, err := regexp.MatchString(excl.Resource, v.AffectedResourceString) 160 if err != nil { 161 return false, fmt.Errorf("match string: %w", err) 162 } 163 if !matchedResource && !matchedResourceString { 164 continue 165 } 166 } 167 return true, nil 168 } 169 return false, nil 170 } 171 172 // filterVulns takes a list of vulnerabilities and filters out those 173 // vulnerabilities that should be excluded based on the [Writer] 174 // configuration. 175 func (writer Writer) filterVulns(vulns []vulnerability) []vulnerability { 176 // Sort the results by severity in reverse order. 177 slices.SortFunc(vulns, func(a, b vulnerability) int { 178 return cmp.Compare(b.Severity, a.Severity) 179 }) 180 181 fvulns := make([]vulnerability, 0) 182 for _, v := range vulns { 183 if v.Severity < writer.minSeverity { 184 break 185 } 186 if v.excluded { 187 continue 188 } 189 fvulns = append(fvulns, v) 190 } 191 return fvulns 192 } 193 194 // calculateExitCode returns an error code depending on the vulnerabilities found, 195 // as long as the severity of the vulnerabilities is higher or equal than the 196 // min severity configured in the writer. For that it makes use of the summary. 197 // 198 // See [ExitCode] for more information about exit codes. 199 func (writer Writer) calculateExitCode(sum summary) ExitCode { 200 for sev := config.SeverityCritical; sev >= writer.minSeverity; sev-- { 201 if sum.count[sev] > 0 { 202 diff := sev - config.SeverityInfo 203 return ExitCodeInfo + ExitCode(diff) 204 } 205 } 206 return 0 207 } 208 209 // vulnerability represents a vulnerability found by a check. 210 type vulnerability struct { 211 report.Vulnerability 212 CheckData report.CheckData `json:"check_data"` 213 Severity config.Severity `json:"severity"` 214 excluded bool 215 } 216 217 // A printer renders a Vulcan report in a specific format. 218 type printer interface { 219 Print(w io.Writer, vulns []vulnerability, sum summary) error 220 } 221 222 // scoreToSeverity converts a CVSS score into a [config.Severity]. 223 // To calculate the severity we are using the [severity ratings] 224 // provided by the NVD. 225 // 226 // [severity ratings]: https://nvd.nist.gov/vuln-metrics/cvss 227 func scoreToSeverity(score float32) config.Severity { 228 switch { 229 case score >= 9.0: 230 return config.SeverityCritical 231 case score >= 7.0: 232 return config.SeverityHigh 233 case score >= 4.0: 234 return config.SeverityMedium 235 case score >= 0.1: 236 return config.SeverityLow 237 default: 238 return config.SeverityInfo 239 } 240 } 241 242 // summary represents the statistics of the results. 243 type summary struct { 244 count map[config.Severity]int 245 excluded int 246 } 247 248 // mkSummary counts the number vulnerabilities per severity and the 249 // number of excluded vulnerabilities. The excluded vulnerabilities are 250 // not considered in the count per severity. 251 func mkSummary(vulns []vulnerability) (summary, error) { 252 if len(vulns) == 0 { 253 return summary{}, nil 254 } 255 256 sum := summary{ 257 count: make(map[config.Severity]int), 258 } 259 for _, vuln := range vulns { 260 if !vuln.Severity.IsValid() { 261 return summary{}, fmt.Errorf("invalid severity: %v", vuln.Severity) 262 } 263 if vuln.excluded { 264 sum.excluded++ 265 } else { 266 sum.count[vuln.Severity]++ 267 } 268 } 269 return sum, nil 270 } 271 272 // ExitCode represents an exit code depending on the vulnerabilities found. 273 type ExitCode int 274 275 // Exit codes depending on the maximum severity found. 276 const ( 277 ExitCodeCritical ExitCode = 104 278 ExitCodeHigh ExitCode = 103 279 ExitCodeMedium ExitCode = 102 280 ExitCodeLow ExitCode = 101 281 ExitCodeInfo ExitCode = 100 282 )