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  )