Skip to content

Commit

Permalink
all: collect metrics
Browse files Browse the repository at this point in the history
  • Loading branch information
seilagamo committed Nov 2, 2023
1 parent 3d37036 commit aeff0a4
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 46 deletions.
31 changes: 26 additions & 5 deletions cmd/lava/internal/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -38,8 +40,9 @@ output is disabled in the following cases:
}

var (
cfgfile = CmdRun.Flag.String("c", "lava.yaml", "config file")
forceColor = CmdRun.Flag.Bool("forcecolor", false, "force colorized output")
cfgfile = CmdRun.Flag.String("c", "lava.yaml", "config file")
forceColor = CmdRun.Flag.Bool("forcecolor", false, "force colorized output")
metricsEnabled = CmdRun.Flag.Bool("metrics", false, "generate metrics files")
)

func init() {
Expand All @@ -56,23 +59,31 @@ func run(args []string) error {
color.NoColor = false
}

collector, err := metrics.NewCollector()
if err != nil {
return fmt.Errorf("new JSON metrics collector: %w", err)
}
executionTime := time.Now()
collector.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)
}
collector.Collect("lava_version", cfg.LavaVersion)
collector.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)
}

rw, err := report.NewWriter(cfg.ReportConfig)
rw, err := report.NewWriter(cfg.ReportConfig, *collector)
if err != nil {
return fmt.Errorf("new writer: %w", err)
}
Expand All @@ -83,6 +94,16 @@ func run(args []string) error {
return fmt.Errorf("render report: %w", err)
}

collector.Collect("exit_code", exitCode)
finishTime := time.Now()
duration := finishTime.Sub(executionTime)
collector.Collect("duration", duration.String())

if *metricsEnabled {
if err = collector.Write(); err != nil {
return fmt.Errorf("emit metrics: %w", err)
}
}
os.Exit(int(exitCode))

return nil
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ 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 decides is a metrics report is printed.
Metrics bool `yaml:"metrics"`
}

// Target represents the target of a scan.
Expand Down
77 changes: 77 additions & 0 deletions internal/metrics/metrics_collector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2023 Adevinta

// Package metrics collects all kind of Lava metrics.
package metrics

import (
"encoding/json"
"fmt"
"io"
"os"
)

// Collector represents a metrics collector.
type Collector struct {
Metrics map[string]interface{}
Emitter emitter
}

// NewCollector creates a new metrics collector.
func NewCollector() (*Collector, error) {
return &Collector{
Metrics: make(map[string]interface{}),
Emitter: NewJSONEmitter(),
}, nil
}

// Collect stores the metric.
func (c *Collector) Collect(metric string, value interface{}) {
c.Metrics[metric] = value
}

// Write renders the metrics.
func (c *Collector) Write() error {
if err := c.Emitter.Emit(c.Metrics); err != nil {
return fmt.Errorf("emit metrics: %w", err)
}
return nil
}

// A Emitter emit metrics.
type emitter interface {
Emit(metrics map[string]interface{}) error
}

// JSONEmitter emits metrics in JSON format.
type JSONEmitter struct {
createWriter func() (io.WriteCloser, error)
}

// NewJSONEmitter create a metrics emitter.
func NewJSONEmitter() *JSONEmitter {
return &JSONEmitter{
// For lazy file creation.
createWriter: func() (io.WriteCloser, error) {
f, err := os.Create("metrics.json")
if err != nil {
return nil, fmt.Errorf("create file: %w", err)
}
return f, nil
},
}
}

// Emit renders the metrics in JSON format.
func (c *JSONEmitter) Emit(metrics map[string]interface{}) error {
w, err := c.createWriter()
if err != nil {
return fmt.Errorf("create writer: %w", err)
}
defer w.Close()
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(metrics); err != nil {
return fmt.Errorf("encode report: %w", err)
}
return nil
}
85 changes: 85 additions & 0 deletions internal/metrics/metrics_collector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2023 Adevinta

package metrics

import (
"bytes"
"encoding/json"
"io"
"testing"

"github.com/google/go-cmp/cmp"
)

type mockWriteClose struct {
*bytes.Buffer
}

func (mwc *mockWriteClose) Close() error {
return nil
}

func TestMetrics_JSONCollector(t *testing.T) {
tests := []struct {
name string
metrics map[string]interface{}
want map[string]interface{}
}{
{
name: "Happy Path",
metrics: map[string]interface{}{
"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]interface{}{
"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",
},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
je := &JSONEmitter{
createWriter: func() (io.WriteCloser, error) {
return &mockWriteClose{buf}, nil
},
}
mc := Collector{
Metrics: make(map[string]interface{}),
Emitter: je,
}
for key, value := range tt.metrics {
mc.Collect(key, value)
}

if err := mc.Write(); err != nil {
t.Fatalf("emit metrics %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Errorf("unmarshal json metrics: %v", err)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("metrics mismatch (-want +got):\n%v", diff)
}
})
}
}
80 changes: 45 additions & 35 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@ 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.
type Writer struct {
prn printer
w io.WriteCloser
minSeverity config.Severity
exclusions []config.Exclusion
prn printer
w io.WriteCloser
minSeverity config.Severity
exclusions []config.Exclusion
printMetrics bool
collector metrics.Collector
}

// NewWriter creates a new instance of a report writer.
func NewWriter(cfg config.ReportConfig) (Writer, error) {
func NewWriter(cfg config.ReportConfig, collector metrics.Collector) (Writer, error) {
var prn printer
switch cfg.Format {
case config.OutputFormatHuman:
Expand All @@ -49,10 +52,12 @@ func NewWriter(cfg config.ReportConfig) (Writer, error) {
}

return Writer{
prn: prn,
w: w,
minSeverity: cfg.Severity,
exclusions: cfg.Exclusions,
prn: prn,
w: w,
minSeverity: cfg.Severity,
exclusions: cfg.Exclusions,
printMetrics: cfg.Metrics,
collector: collector,
}, nil
}

Expand All @@ -65,7 +70,8 @@ func (writer Writer) Write(er engine.Report) (ExitCode, error) {
if err != nil {
return 0, fmt.Errorf("parse report: %w", err)
}
sum, err := mkSummary(vulns)

sum, err := writer.mkSummary(vulns)
if err != nil {
return 0, fmt.Errorf("calculate summary: %w", err)
}
Expand Down Expand Up @@ -96,7 +102,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)
Expand Down Expand Up @@ -196,6 +201,35 @@ func (writer Writer) calculateExitCode(sum summary) ExitCode {
return 0
}

// mkSummary counts the number vulnerabilities per severity and the
// number of excluded vulnerabilities. The excluded vulnerabilities are
// not considered in the count per severity.
func (writer Writer) mkSummary(vulns []vulnerability) (summary, error) {
if len(vulns) == 0 {
writer.collector.Collect("vulnerabilities", make(map[string]int))
writer.collector.Collect("excluded", 0)
return summary{}, nil
}
sum := summary{
count: make(map[config.Severity]int),
}
countMetrics := make(map[string]int)
for _, vuln := range vulns {
if !vuln.Severity.IsValid() {
return summary{}, fmt.Errorf("invalid severity: %v", vuln.Severity)
}
if vuln.excluded {
sum.excluded++
} else {
sum.count[vuln.Severity]++
countMetrics[vuln.Severity.String()]++
}
}
writer.collector.Collect("vulnerabilities", countMetrics)
writer.collector.Collect("excluded", sum.excluded)
return sum, nil
}

// vulnerability represents a vulnerability found by a check.
type vulnerability struct {
report.Vulnerability
Expand Down Expand Up @@ -235,30 +269,6 @@ type summary struct {
excluded int
}

// mkSummary counts the number vulnerabilities per severity and the
// number of excluded vulnerabilities. The excluded vulnerabilities are
// not considered in the count per severity.
func mkSummary(vulns []vulnerability) (summary, error) {
if len(vulns) == 0 {
return summary{}, nil
}

sum := summary{
count: make(map[config.Severity]int),
}
for _, vuln := range vulns {
if !vuln.Severity.IsValid() {
return summary{}, fmt.Errorf("invalid severity: %v", vuln.Severity)
}
if vuln.excluded {
sum.excluded++
} else {
sum.count[vuln.Severity]++
}
}
return sum, nil
}

// ExitCode represents an exit code depending on the vulnerabilities found.
type ExitCode int

Expand Down
Loading

0 comments on commit aeff0a4

Please sign in to comment.