github.com/adevinta/lava@v0.7.2/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/adevinta/lava/internal/config" 19 "github.com/adevinta/lava/internal/engine" 20 "github.com/adevinta/lava/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 summ, err := mkSummary(vulns) 75 if err != nil { 76 return 0, fmt.Errorf("calculate summary: %w", err) 77 } 78 79 metrics.Collect("excluded_vulnerability_count", summ.excluded) 80 metrics.Collect("vulnerability_count", summ.count) 81 82 fvulns := writer.filterVulns(vulns) 83 status := mkStatus(er) 84 exitCode := writer.calculateExitCode(summ, status) 85 86 if err = writer.prn.Print(writer.w, fvulns, summ, status); err != nil { 87 return exitCode, fmt.Errorf("print report: %w", err) 88 } 89 90 return exitCode, nil 91 } 92 93 // Close closes the [Writer]. 94 func (writer Writer) Close() error { 95 if !writer.isStdout { 96 if err := writer.w.Close(); err != nil { 97 return fmt.Errorf("close writer: %w", err) 98 } 99 } 100 return nil 101 } 102 103 // parseReport converts the provided [engine.Report] into a list of 104 // vulnerabilities. It calculates the severity of each vulnerability 105 // based on its score and determines if the vulnerability is excluded 106 // according to the [Writer] configuration. 107 func (writer Writer) parseReport(er engine.Report) ([]vulnerability, error) { 108 var vulns []vulnerability 109 for _, r := range er { 110 for _, vuln := range r.ResultData.Vulnerabilities { 111 severity := scoreToSeverity(vuln.Score) 112 excluded, err := writer.isExcluded(vuln, r.Target) 113 if err != nil { 114 return nil, fmt.Errorf("vulnerability exlusion: %w", err) 115 } 116 v := vulnerability{ 117 CheckData: r.CheckData, 118 Vulnerability: vuln, 119 Severity: severity, 120 excluded: excluded, 121 } 122 vulns = append(vulns, v) 123 } 124 } 125 return vulns, nil 126 } 127 128 // isExcluded returns whether the provided [report.Vulnerability] is 129 // excluded based on the [Writer] configuration and the affected target. 130 func (writer Writer) isExcluded(v report.Vulnerability, target string) (bool, error) { 131 for _, excl := range writer.exclusions { 132 if excl.Fingerprint != "" && v.Fingerprint != excl.Fingerprint { 133 continue 134 } 135 136 if excl.Summary != "" { 137 matched, err := regexp.MatchString(excl.Summary, v.Summary) 138 if err != nil { 139 return false, fmt.Errorf("match string: %w", err) 140 } 141 if !matched { 142 continue 143 } 144 } 145 146 if excl.Target != "" { 147 matched, err := regexp.MatchString(excl.Target, target) 148 if err != nil { 149 return false, fmt.Errorf("match string: %w", err) 150 } 151 if !matched { 152 continue 153 } 154 } 155 156 if excl.Resource != "" { 157 matchedResource, err := regexp.MatchString(excl.Resource, v.AffectedResource) 158 if err != nil { 159 return false, fmt.Errorf("match string: %w", err) 160 } 161 matchedResourceString, err := regexp.MatchString(excl.Resource, v.AffectedResourceString) 162 if err != nil { 163 return false, fmt.Errorf("match string: %w", err) 164 } 165 if !matchedResource && !matchedResourceString { 166 continue 167 } 168 } 169 return true, nil 170 } 171 return false, nil 172 } 173 174 // filterVulns takes a list of vulnerabilities and filters out those 175 // vulnerabilities that should be excluded based on the [Writer] 176 // configuration. 177 func (writer Writer) filterVulns(vulns []vulnerability) []vulnerability { 178 // Sort the results by severity in reverse order. 179 slices.SortFunc(vulns, func(a, b vulnerability) int { 180 return cmp.Compare(b.Severity, a.Severity) 181 }) 182 183 fvulns := make([]vulnerability, 0) 184 for _, v := range vulns { 185 if v.Severity < writer.minSeverity { 186 break 187 } 188 if v.excluded { 189 continue 190 } 191 fvulns = append(fvulns, v) 192 } 193 return fvulns 194 } 195 196 // calculateExitCode returns an error code depending on the vulnerabilities found, 197 // as long as the severity of the vulnerabilities is higher or equal than the 198 // min severity configured in the writer. For that it makes use of the summary. 199 // 200 // See [ExitCode] for more information about exit codes. 201 func (writer Writer) calculateExitCode(summ summary, status []checkStatus) ExitCode { 202 for _, cs := range status { 203 if cs.Status != "FINISHED" { 204 return ExitCodeCheckError 205 } 206 } 207 208 for sev := config.SeverityCritical; sev >= writer.minSeverity; sev-- { 209 if summ.count[sev] > 0 { 210 diff := sev - config.SeverityInfo 211 return ExitCodeInfo + ExitCode(diff) 212 } 213 } 214 return 0 215 } 216 217 // vulnerability represents a vulnerability found by a check. 218 type vulnerability struct { 219 report.Vulnerability 220 CheckData report.CheckData `json:"check_data"` 221 Severity config.Severity `json:"severity"` 222 excluded bool 223 } 224 225 // A printer renders a Vulcan report in a specific format. 226 type printer interface { 227 Print(w io.Writer, vulns []vulnerability, summ summary, status []checkStatus) error 228 } 229 230 // scoreToSeverity converts a CVSS score into a [config.Severity]. 231 // To calculate the severity we are using the [severity ratings] 232 // provided by the NVD. 233 // 234 // [severity ratings]: https://nvd.nist.gov/vuln-metrics/cvss 235 func scoreToSeverity(score float32) config.Severity { 236 switch { 237 case score >= 9.0: 238 return config.SeverityCritical 239 case score >= 7.0: 240 return config.SeverityHigh 241 case score >= 4.0: 242 return config.SeverityMedium 243 case score >= 0.1: 244 return config.SeverityLow 245 default: 246 return config.SeverityInfo 247 } 248 } 249 250 // summary represents the statistics of the results. 251 type summary struct { 252 count map[config.Severity]int 253 excluded int 254 } 255 256 // mkSummary counts the number vulnerabilities per severity and the 257 // number of excluded vulnerabilities. The excluded vulnerabilities are 258 // not considered in the count per severity. 259 func mkSummary(vulns []vulnerability) (summary, error) { 260 if len(vulns) == 0 { 261 return summary{}, nil 262 } 263 264 summ := summary{ 265 count: make(map[config.Severity]int), 266 } 267 for _, vuln := range vulns { 268 if !vuln.Severity.IsValid() { 269 return summary{}, fmt.Errorf("invalid severity: %v", vuln.Severity) 270 } 271 if vuln.excluded { 272 summ.excluded++ 273 } else { 274 summ.count[vuln.Severity]++ 275 } 276 } 277 return summ, nil 278 } 279 280 // checkStatus represents the status of a check after the scan has 281 // finished. 282 type checkStatus struct { 283 Checktype string 284 Target string 285 Status string 286 } 287 288 // mkStatus returns the status of every check after the scan has 289 // finished. 290 func mkStatus(er engine.Report) []checkStatus { 291 var status []checkStatus 292 for _, r := range er { 293 cs := checkStatus{ 294 Checktype: r.ChecktypeName, 295 Target: r.Target, 296 Status: r.Status, 297 } 298 status = append(status, cs) 299 } 300 return status 301 } 302 303 // ExitCode represents an exit code depending on the vulnerabilities found. 304 type ExitCode int 305 306 // Exit codes depending on the maximum severity found. 307 const ( 308 ExitCodeCheckError ExitCode = 3 309 ExitCodeInfo ExitCode = 100 310 ExitCodeLow ExitCode = 101 311 ExitCodeMedium ExitCode = 102 312 ExitCodeHigh ExitCode = 103 313 ExitCodeCritical ExitCode = 104 314 )