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  )