codecatalyst-runner/pkg/workflows/report_processor.go (227 lines of code) (raw):
package workflows
import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"github.com/aws/codecatalyst-runner-cli/command-runner/pkg/common"
"github.com/aws/codecatalyst-runner-cli/command-runner/pkg/runner"
"github.com/owenrumney/go-sarif/sarif"
"github.com/rs/zerolog/log"
)
type reportHandler func(reader io.Reader, report *Report) error
// ReportProcessor looks for reports created by the action and fails if they dont meet the successCriteria.
// Results are saved in the provided report parameter.
func ReportProcessor(
report *Report,
successCriteria *SuccessCriteria,
reportDir string,
) runner.Feature {
return func(ctx context.Context, plan runner.Plan, e runner.PlanExecutor) error {
log.Ctx(ctx).Debug().Msg("ENTER ReportProcessor")
err := e(ctx)
if processError := newReportProcessor(
reportDir,
report,
successCriteria,
)(ctx); processError != nil {
log.Warn().Err(processError).Msg("Failed to process report")
}
if report.Result != ResultSucceeded {
err = fmt.Errorf("report status %s", report.Result)
}
log.Ctx(ctx).Debug().Msg("EXIT ReportProcessor")
return err
}
}
func newReportProcessor(reportsDir string, report *Report, successCriteria *SuccessCriteria) common.Executor {
handlers := []reportHandler{
sarifReportHandler(successCriteria.VulnerabilityThreshold),
}
return func(ctx context.Context) error {
err := filepath.WalkDir(reportsDir, func(path string, d fs.DirEntry, err error) error {
if d != nil && d.Type().IsRegular() {
for _, handler := range handlers {
reportFile, err := os.Open(path)
if err != nil {
return err
}
defer reportFile.Close()
if err := handler(reportFile, report); err != nil {
return nil
}
}
}
return nil
})
if err != nil {
return err
}
if report.Result == "" {
report.Result = ResultSucceeded
}
return nil
}
}
func sarifReportHandler(severityThreshold VulnerabilitySeverity) reportHandler {
return func(reader io.Reader, report *Report) error {
decoder := json.NewDecoder(reader)
sarifReport := new(sarif.Report)
if err := decoder.Decode(sarifReport); err != nil {
log.Debug().Err(err).Msgf("Skipping non-sarif report")
return nil
}
if strings.HasPrefix(path.Base(sarifReport.Schema), "sarif") {
for _, run := range sarifReport.Runs {
for _, r := range run.Results {
// only consider results with empty 'kind' or 'kind' of 'fail'
if r.Kind == nil || *r.Kind == "" || *r.Kind == "fail" {
severity := levelToSeverity(r.Level)
log.Debug().Msgf("Got result with severity %s (threshold=%s)", severity, severityThreshold)
if severityExceedsThreshold(severityThreshold, severity) && len(r.Suppressions) == 0 {
report.Result = ResultFailed
}
report.Vulnerabilities = append(report.Vulnerabilities, Vulnerability{
Severity: severity,
RuleID: safeString(r.RuleID),
Message: safeString(r.Message.Text),
Locations: convertLocations(r.Locations),
Suppressions: convertSuppressions(r.Suppressions),
})
}
}
}
}
return nil
}
}
func severityExceedsThreshold(severityThreshold VulnerabilitySeverity, severity VulnerabilitySeverity) bool {
return severityOrdinal(severity) >= severityOrdinal(severityThreshold)
}
func severityOrdinal(severity VulnerabilitySeverity) int {
switch severity {
case VulnerabilitySeverityCritical:
return 1000
case VulnerabilitySeverityHigh:
return 500
case VulnerabilitySeverityMedium:
return 100
case VulnerabilitySeverityLow:
return 10
case VulnerabilitySeverityInformational:
return 1
default:
return 0
}
}
func convertLocations(sarifLocations []*sarif.Location) []Location {
if sarifLocations == nil {
return nil
}
locations := make([]Location, 0)
for _, l := range sarifLocations {
if l == nil {
continue
}
location := Location{}
if l.PhysicalLocation != nil {
if l.PhysicalLocation.ArtifactLocation != nil {
location.URI = safeString(l.PhysicalLocation.ArtifactLocation.URI)
}
if l.PhysicalLocation.Region != nil {
location.StartLine = l.PhysicalLocation.Region.StartLine
location.EndLine = l.PhysicalLocation.Region.EndLine
if l.PhysicalLocation.Region.Snippet != nil {
location.Snippet = safeString(l.PhysicalLocation.Region.Snippet.Text)
}
}
}
locations = append(locations, location)
}
return locations
}
func convertSuppressions(sarifSuppressions []*sarif.Suppression) []Suppression {
if sarifSuppressions == nil {
return nil
}
suppressions := make([]Suppression, 0)
for _, s := range sarifSuppressions {
if s == nil {
continue
}
suppression := Suppression{
Kind: s.Kind,
Justification: safeString(s.Justification),
}
suppressions = append(suppressions, suppression)
}
return suppressions
}
func levelToSeverity(level *string) VulnerabilitySeverity {
if level == nil {
return VulnerabilitySeverityMedium
}
switch *level {
case "error":
return VulnerabilitySeverityHigh
case "warning":
return VulnerabilitySeverityMedium
case "note":
return VulnerabilitySeverityLow
case "none":
return VulnerabilitySeverityInformational
default:
return VulnerabilitySeverityMedium
}
}
func safeString(s *string) string {
if s == nil {
return ""
}
return *s
}
// Report object is the aggregation of all reports detected in the action
type Report struct {
Result Result `json:"codecatalyst_action_result"` // result of the report
PassRate *float32 `json:"codecatalyst_action_passRate,omitempty"` // number between 0 and 100 representing the percentage of tests that passed
LineCoverage *float32 `json:"codecatalyst_action_lineCoverage,omitempty"` // number between 0 and 100 representing the percentage of lines that were covered by tests
BranchCoverage *float32 `json:"codecatalyst_action_branchCoverage,omitempty"` // number between 0 and 100 representing the percentage of branches that were covered by tests
Vulnerabilities []Vulnerability `json:"codecatalyst_action_vulnerabilities"` // list of vulnerabilities found
}
// Result for a report, either SUCCEEDED or FAILED
type Result string
const (
// ResultSucceeded indicates that the action passed
ResultSucceeded Result = "SUCCEEDED"
// ResultFailed indicates that the action failed
ResultFailed Result = "FAILED"
)
// Vulnerability found during an execution of an action
type Vulnerability struct {
Severity VulnerabilitySeverity // severity of the vulnerability
RuleID string // ID of the rule that found the vulnerability
Message string // description of the vulnerability
Locations []Location // locations of the vulnerability
Suppressions []Suppression // list of suppressions applied to the vulnerability
}
// Location of a vulnerability
type Location struct {
URI string // uri of the location
StartLine *int `json:",omitempty"` // first line number of a location
EndLine *int `json:",omitempty"` // last line number of a location
Snippet string // portion of the artifact identified in the location
}
// Suppression object describes a request to suppress a result
type Suppression struct {
Kind string // type of suppression, one of: inSource or external
Justification string // user-supplied string explaining why the result was suppressed
}
// SuccessCriteria defines the required results of test reports for an action to pass
type SuccessCriteria struct {
PassRate float32 `yaml:"passRate"` // number between 0 and 100 representing the percentage of tests that must pass
LineCoverage float32 `yaml:"lineCoverage"` // number between 0 and 100 representing the percentage of lines that must be covered by tests
BranchCoverage float32 `yaml:"branchCoverage"` // number between 0 and 100 representing the percentage of branches that must be covered by tests
VulnerabilityThreshold VulnerabilitySeverity `yaml:"vulnerabilityThreshold"` // the max severity of the vulnerabilities allowed
}
// VulnerabilitySeverity describes the severity of a vulnerability
type VulnerabilitySeverity string
const (
// VulnerabilitySeverityCritical is critical severity
VulnerabilitySeverityCritical VulnerabilitySeverity = "CRITICAL"
// VulnerabilitySeverityHigh is high severity
VulnerabilitySeverityHigh VulnerabilitySeverity = "HIGH"
// VulnerabilitySeverityMedium is medium severity
VulnerabilitySeverityMedium VulnerabilitySeverity = "MEDIUM"
// VulnerabilitySeverityLow is low severity
VulnerabilitySeverityLow VulnerabilitySeverity = "LOW"
// VulnerabilitySeverityInformational is informational severity
VulnerabilitySeverityInformational VulnerabilitySeverity = "INFORMATIONAL"
)