diff --git a/cmd/lava/internal/run/run.go b/cmd/lava/internal/run/run.go index 52f7e40..f76ca14 100644 --- a/cmd/lava/internal/run/run.go +++ b/cmd/lava/internal/run/run.go @@ -8,12 +8,14 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/fatih/color" "github.com/adevinta/lava/cmd/lava/internal/base" "github.com/adevinta/lava/internal/config" "github.com/adevinta/lava/internal/engine" + "github.com/adevinta/lava/internal/metrics" "github.com/adevinta/lava/internal/report" ) @@ -56,17 +58,21 @@ func run(args []string) error { color.NoColor = false } + executionTime := time.Now() + metrics.Collect("execution_time", executionTime) + cfg, err := config.ParseFile(*cfgfile) if err != nil { return fmt.Errorf("parse config file: %w", err) } - if err := os.Chdir(filepath.Dir(*cfgfile)); err != nil { + if err = os.Chdir(filepath.Dir(*cfgfile)); err != nil { return fmt.Errorf("change directory: %w", err) } + metrics.Collect("lava_version", cfg.LavaVersion) + metrics.Collect("targets", cfg.Targets) base.LogLevel.Set(cfg.LogLevel) - er, err := engine.Run(cfg.ChecktypesURLs, cfg.Targets, cfg.AgentConfig) if err != nil { return fmt.Errorf("run: %w", err) @@ -83,6 +89,15 @@ func run(args []string) error { return fmt.Errorf("render report: %w", err) } + metrics.Collect("exit_code", exitCode) + duration := time.Since(executionTime) + metrics.Collect("duration", duration.String()) + + if cfg.ReportConfig.Metrics != "" { + if err = metrics.WriteFile(cfg.ReportConfig.Metrics); err != nil { + return fmt.Errorf("write metrics: %w", err) + } + } os.Exit(int(exitCode)) return nil diff --git a/internal/config/config.go b/internal/config/config.go index 16b53e6..73f2b0d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -148,6 +148,11 @@ type ReportConfig struct { // Exclusions is a list of findings that will be ignored. For // instance, accepted risks, false positives, etc. Exclusions []Exclusion `yaml:"exclusions"` + + // Metrics is the file where the metrics will be written. + // If Metrics is an empty string or not specified in the yaml file, then + // the metrics report is not saved. + Metrics string `yaml:"metrics"` } // Target represents the target of a scan. @@ -220,7 +225,7 @@ func (s Severity) String() string { } // MarshalText encode a [Severity] as a text. -func (s *Severity) MarshalText() (text []byte, err error) { +func (s Severity) MarshalText() (text []byte, err error) { if !s.IsValid() { return nil, ErrInvalidSeverity } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..bd00eac --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,70 @@ +// Copyright 2023 Adevinta + +// Package metrics collects Lava execution metrics. +package metrics + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sync" +) + +// DefaultCollector is the default [Collector]. +var DefaultCollector = NewCollector() + +// Collector represents a metrics collector. +type Collector struct { + mutex sync.Mutex + metrics map[string]any +} + +// NewCollector returns a new metrics collector. +func NewCollector() *Collector { + return &Collector{ + metrics: make(map[string]any), + } +} + +// Collect records a metric with the provided name and value. +func (c *Collector) Collect(name string, value any) { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.metrics[name] = value +} + +// Write writes the metrics to the specified [io.Writer]. +func (c *Collector) Write(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(c.metrics); err != nil { + return fmt.Errorf("encode JSON: %w", err) + } + return nil +} + +// Collect records a metric with the provided name and value using +// [DefaultCollector]. +func Collect(name string, value any) { + DefaultCollector.Collect(name, value) +} + +// Write writes the collected metrics to the specified [io.Writer] +// using [DefaultCollector]. +func Write(w io.Writer) error { + return DefaultCollector.Write(w) +} + +// WriteFile writes the collected metrics into the specified file +// using [DefaultCollector]. +func WriteFile(file string) error { + f, err := os.Create(file) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() + + return Write(f) +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..f622122 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,118 @@ +// Copyright 2023 Adevinta + +package metrics + +import ( + "bytes" + "encoding/json" + "os" + "path" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var testdata = []struct { + name string + metrics map[string]any + want map[string]any +}{ + { + name: "happy path", + metrics: map[string]any{ + "metric 1": "metric value 1", + "metric 2": 12345, + "metric 3": 25.5, + "metric 4": map[string]int{ + "key 1": 1, + "key 2": 2, + }, + "metric 5": []string{ + "one", "two", "three", + }, + }, + want: map[string]any{ + "metric 1": "metric value 1", + "metric 2": float64(12345), + "metric 3": 25.5, + "metric 4": map[string]any{ + "key 1": float64(1), + "key 2": float64(2), + }, + "metric 5": []any{ + "one", "two", "three", + }, + }, + }, +} + +func TestWrite(t *testing.T) { + for _, tt := range testdata { + t.Run(tt.name, func(t *testing.T) { + oldDefaultCollector := DefaultCollector + defer func() { DefaultCollector = oldDefaultCollector }() + + DefaultCollector = NewCollector() + + var buf bytes.Buffer + + for key, value := range tt.metrics { + Collect(key, value) + } + + if err := Write(&buf); err != nil { + t.Fatalf("error writing metrics: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Errorf("error decoding JSON metrics: %v", err) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("metrics mismatch (-want +got):\n%v", diff) + } + }) + } +} + +func TestWriteFile(t *testing.T) { + for _, tt := range testdata { + t.Run(tt.name, func(t *testing.T) { + oldDefaultCollector := DefaultCollector + defer func() { DefaultCollector = oldDefaultCollector }() + + DefaultCollector = NewCollector() + + tmpPath, err := os.MkdirTemp("", "") + if err != nil { + t.Fatalf("error creating temp dir: %v", err) + } + defer os.RemoveAll(tmpPath) + + file := path.Join(tmpPath, "metrics.json") + + for key, value := range tt.metrics { + Collect(key, value) + } + + if err = WriteFile(file); err != nil { + t.Fatalf("error writing metrics: %v", err) + } + + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("error reading metrics file: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Errorf("error decoding JSON metrics: %v", err) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("metrics mismatch (-want +got):\n%v", diff) + } + }) + } +} diff --git a/internal/report/report.go b/internal/report/report.go index 530af69..463169b 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -17,6 +17,7 @@ import ( "github.com/adevinta/lava/internal/config" "github.com/adevinta/lava/internal/engine" + "github.com/adevinta/lava/internal/metrics" ) // Writer represents a Lava report writer. @@ -65,13 +66,16 @@ func (writer Writer) Write(er engine.Report) (ExitCode, error) { if err != nil { return 0, fmt.Errorf("parse report: %w", err) } + sum, err := mkSummary(vulns) if err != nil { return 0, fmt.Errorf("calculate summary: %w", err) } + + metrics.Collect("excluded", sum.excluded) + metrics.Collect("vulnerabilities", sum.count) exitCode := writer.calculateExitCode(sum) fvulns := writer.filterVulns(vulns) - if err = writer.prn.Print(writer.w, fvulns, sum); err != nil { return exitCode, fmt.Errorf("print report: %w", err) } @@ -96,7 +100,6 @@ func (writer Writer) parseReport(er engine.Report) ([]vulnerability, error) { for _, r := range er { for _, vuln := range r.ResultData.Vulnerabilities { severity := scoreToSeverity(vuln.Score) - excluded, err := writer.isExcluded(vuln, r.Target) if err != nil { return nil, fmt.Errorf("vulnerability exlusion: %w", err)