github.com/adevinta/lava@v0.7.2/internal/config/config.go (about)

     1  // Copyright 2023 Adevinta
     2  
     3  // Package config implements parsing of Lava configurations.
     4  package config
     5  
     6  import (
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"log/slog"
    11  	"os"
    12  	"regexp"
    13  	"strings"
    14  
    15  	agentconfig "github.com/adevinta/vulcan-agent/config"
    16  	types "github.com/adevinta/vulcan-types"
    17  	"golang.org/x/mod/semver"
    18  	"gopkg.in/yaml.v3"
    19  
    20  	"github.com/adevinta/lava/internal/assettypes"
    21  )
    22  
    23  var (
    24  	// ErrInvalidLavaVersion means that the Lava version does not
    25  	// have a valid format according to the Semantic Versioning
    26  	// Specification.
    27  	ErrInvalidLavaVersion = errors.New("invalid Lava version")
    28  
    29  	// ErrNoChecktypeURLs means that no checktypes URLs were
    30  	// specified.
    31  	ErrNoChecktypeURLs = errors.New("no checktype catalogs")
    32  
    33  	// ErrNoTargets means that no targets were specified.
    34  	ErrNoTargets = errors.New("no targets")
    35  
    36  	// ErrNoTargetIdentifier means that the target does not have
    37  	// an identifier.
    38  	ErrNoTargetIdentifier = errors.New("no target identifier")
    39  
    40  	// ErrNoTargetAssetType means that the target does not have an
    41  	// asset type.
    42  	ErrNoTargetAssetType = errors.New("no target asset type")
    43  
    44  	// ErrInvalidAssetType means that the asset type is invalid.
    45  	ErrInvalidAssetType = errors.New("invalid asset type")
    46  
    47  	// ErrInvalidSeverity means that the severity is invalid.
    48  	ErrInvalidSeverity = errors.New("invalid severity")
    49  
    50  	// ErrInvalidOutputFormat means that the output format is
    51  	// invalid.
    52  	ErrInvalidOutputFormat = errors.New("invalid output format")
    53  )
    54  
    55  // Config represents a Lava configuration.
    56  type Config struct {
    57  	// LavaVersion is the minimum required version of Lava.
    58  	LavaVersion string `yaml:"lava"`
    59  
    60  	// AgentConfig is the configuration of the vulcan-agent.
    61  	AgentConfig AgentConfig `yaml:"agent"`
    62  
    63  	// ReportConfig is the configuration of the report.
    64  	ReportConfig ReportConfig `yaml:"report"`
    65  
    66  	// ChecktypeURLs is a list of URLs pointing to checktype
    67  	// catalogs.
    68  	ChecktypeURLs []string `yaml:"checktypes"`
    69  
    70  	// Targets is the list of targets.
    71  	Targets []Target `yaml:"targets"`
    72  
    73  	// LogLevel is the logging level.
    74  	LogLevel slog.Level `yaml:"log"`
    75  }
    76  
    77  // reEnv is used to replace embedded environment variables.
    78  var reEnv = regexp.MustCompile(`\$\{[a-zA-Z_][a-zA-Z_0-9]*\}`)
    79  
    80  // Parse returns a parsed Lava configuration given an [io.Reader].
    81  func Parse(r io.Reader) (Config, error) {
    82  	b, err := io.ReadAll(r)
    83  	if err != nil {
    84  		return Config{}, fmt.Errorf("read config: %w", err)
    85  	}
    86  
    87  	s := reEnv.ReplaceAllStringFunc(string(b), func(match string) string {
    88  		return os.Getenv(match[2 : len(match)-1])
    89  	})
    90  
    91  	dec := yaml.NewDecoder(strings.NewReader(s))
    92  
    93  	// Ensure that the keys in the read data exist as fields in
    94  	// the struct being decoded into.
    95  	dec.KnownFields(true)
    96  
    97  	var cfg Config
    98  	if err := dec.Decode(&cfg); err != nil {
    99  		return Config{}, fmt.Errorf("decode config: %w", err)
   100  	}
   101  	if err := cfg.validate(); err != nil {
   102  		return Config{}, fmt.Errorf("validate config: %w", err)
   103  	}
   104  	return cfg, nil
   105  }
   106  
   107  // ParseFile returns a parsed Lava configuration given a path to a
   108  // file.
   109  func ParseFile(path string) (Config, error) {
   110  	f, err := os.Open(path)
   111  	if err != nil {
   112  		return Config{}, fmt.Errorf("open config file: %w", err)
   113  	}
   114  	defer f.Close()
   115  	return Parse(f)
   116  }
   117  
   118  // validate validates the Lava configuration.
   119  func (c Config) validate() error {
   120  	// Lava version validation.
   121  	if !semver.IsValid(c.LavaVersion) {
   122  		return ErrInvalidLavaVersion
   123  	}
   124  
   125  	// Checktype URLs validation.
   126  	if len(c.ChecktypeURLs) == 0 {
   127  		return ErrNoChecktypeURLs
   128  	}
   129  
   130  	// Targets validation.
   131  	if len(c.Targets) == 0 {
   132  		return ErrNoTargets
   133  	}
   134  	for _, t := range c.Targets {
   135  		if err := t.validate(); err != nil {
   136  			return err
   137  		}
   138  	}
   139  	return nil
   140  }
   141  
   142  // IsCompatible reports whether the configuration is compatible with
   143  // the specified version. An invalid semantic version string is
   144  // considered incompatible.
   145  func (c Config) IsCompatible(v string) bool {
   146  	return semver.Compare(v, c.LavaVersion) >= 0
   147  }
   148  
   149  // AgentConfig is the configuration passed to the vulcan-agent.
   150  type AgentConfig struct {
   151  	// PullPolicy is the pull policy passed to vulcan-agent.
   152  	PullPolicy agentconfig.PullPolicy `yaml:"pullPolicy"`
   153  
   154  	// Parallel is the maximum number of checks that can run in
   155  	// parallel.
   156  	Parallel int `yaml:"parallel"`
   157  
   158  	// Vars is the environment variables required by the Vulcan
   159  	// checktypes.
   160  	Vars map[string]string `yaml:"vars"`
   161  
   162  	// RegistryAuths contains the credentials for a set of
   163  	// container registries.
   164  	RegistryAuths []RegistryAuth `yaml:"registries"`
   165  }
   166  
   167  // ReportConfig is the configuration of the report.
   168  type ReportConfig struct {
   169  	// Severity is the minimum severity required to report a
   170  	// finding.
   171  	Severity Severity `yaml:"severity"`
   172  
   173  	// Format is the output format.
   174  	Format OutputFormat `yaml:"format"`
   175  
   176  	// OutputFile is the path of the output file.
   177  	OutputFile string `yaml:"output"`
   178  
   179  	// Exclusions is a list of findings that will be ignored. For
   180  	// instance, accepted risks, false positives, etc.
   181  	Exclusions []Exclusion `yaml:"exclusions"`
   182  
   183  	// Metrics is the file where the metrics will be written.
   184  	// If Metrics is an empty string or not specified in the yaml file, then
   185  	// the metrics report is not saved.
   186  	Metrics string `yaml:"metrics"`
   187  }
   188  
   189  // Target represents the target of a scan.
   190  type Target struct {
   191  	// Identifier is a string that identifies the target. For
   192  	// instance, a path, a URL, a container image, etc.
   193  	Identifier string `yaml:"identifier"`
   194  
   195  	// AssetType is the asset type of the target.
   196  	AssetType types.AssetType `yaml:"type"`
   197  
   198  	// Options is a list of specific options for the target.
   199  	Options map[string]any `yaml:"options"`
   200  }
   201  
   202  // String returns the string representation of the [Target].
   203  func (t Target) String() string {
   204  	return fmt.Sprintf("%v(%v)", t.AssetType, t.Identifier)
   205  }
   206  
   207  // validate reports whether the target is a valid configuration value.
   208  func (t Target) validate() error {
   209  	if t.Identifier == "" {
   210  		return ErrNoTargetIdentifier
   211  	}
   212  	if t.AssetType == "" {
   213  		return ErrNoTargetAssetType
   214  	}
   215  	if !t.AssetType.IsValid() && !assettypes.IsValid(t.AssetType) {
   216  		return fmt.Errorf("%w: %v", ErrInvalidAssetType, t.AssetType)
   217  	}
   218  	return nil
   219  }
   220  
   221  // RegistryAuth contains the credentials for a container registry.
   222  type RegistryAuth struct {
   223  	// Server is the URL of the registry.
   224  	Server string `yaml:"server"`
   225  
   226  	// Username is the username used to log into the registry.
   227  	Username string `yaml:"username"`
   228  
   229  	// Password is the password used to log into the registry.
   230  	Password string `yaml:"password"`
   231  }
   232  
   233  // String returns the string representation of the [RegistryAuth]
   234  // masking the password.
   235  func (auth RegistryAuth) String() string {
   236  	var s string
   237  	if auth.Username != "" {
   238  		s = auth.Username + ":*****@"
   239  	}
   240  	return s + auth.Server
   241  }
   242  
   243  // Severity is the severity of a given finding.
   244  type Severity int
   245  
   246  // Severity levels.
   247  const (
   248  	SeverityCritical Severity = 1
   249  	SeverityHigh     Severity = 0
   250  	SeverityMedium   Severity = -1
   251  	SeverityLow      Severity = -2
   252  	SeverityInfo     Severity = -3
   253  )
   254  
   255  // severityNames maps each severity name with its level.
   256  var severityNames = map[string]Severity{
   257  	"critical": SeverityCritical,
   258  	"high":     SeverityHigh,
   259  	"medium":   SeverityMedium,
   260  	"low":      SeverityLow,
   261  	"info":     SeverityInfo,
   262  }
   263  
   264  // parseSeverity converts a string into a [Severity] value.
   265  func parseSeverity(severity string) (Severity, error) {
   266  	if val, ok := severityNames[severity]; ok {
   267  		return val, nil
   268  	}
   269  	return Severity(0), fmt.Errorf("%w: %v", ErrInvalidSeverity, severity)
   270  }
   271  
   272  // IsValid checks if a severity is valid.
   273  func (s Severity) IsValid() bool {
   274  	return s >= SeverityInfo && s <= SeverityCritical
   275  }
   276  
   277  // String returns the string representation of the severity.
   278  func (s Severity) String() string {
   279  	for k, v := range severityNames {
   280  		if v == s {
   281  			return k
   282  		}
   283  	}
   284  	return ""
   285  }
   286  
   287  // MarshalText encodes a [Severity] as text. It returns error is the
   288  // severity is not valid.
   289  func (s Severity) MarshalText() (text []byte, err error) {
   290  	if !s.IsValid() {
   291  		return nil, ErrInvalidSeverity
   292  	}
   293  	return []byte(s.String()), nil
   294  }
   295  
   296  // UnmarshalText decodes a [Severity] text into a [Severity] value. It
   297  // returns error if the provided string does not match any known
   298  // severity.
   299  func (s *Severity) UnmarshalText(text []byte) error {
   300  	severity, err := parseSeverity(string(text))
   301  	if err != nil {
   302  		return err
   303  	}
   304  	*s = severity
   305  	return nil
   306  }
   307  
   308  // OutputFormat is the format of the generated report.
   309  type OutputFormat int
   310  
   311  // Output formats available for the report.
   312  const (
   313  	OutputFormatHuman OutputFormat = iota
   314  	OutputFormatJSON
   315  )
   316  
   317  var outputFormatNames = map[string]OutputFormat{
   318  	"human": OutputFormatHuman,
   319  	"json":  OutputFormatJSON,
   320  }
   321  
   322  // parseOutputFormat converts a string into an [OutputFormat] value.
   323  func parseOutputFormat(format string) (OutputFormat, error) {
   324  	if val, ok := outputFormatNames[strings.ToLower(format)]; ok {
   325  		return val, nil
   326  	}
   327  	return OutputFormat(0), fmt.Errorf("%w: %v", ErrInvalidOutputFormat, format)
   328  }
   329  
   330  // String returns the string representation of the output format.
   331  func (f OutputFormat) String() string {
   332  	for k, v := range outputFormatNames {
   333  		if v == f {
   334  			return k
   335  		}
   336  	}
   337  	return ""
   338  }
   339  
   340  // IsValid reports whether the output format is known.
   341  func (f OutputFormat) IsValid() bool {
   342  	for _, v := range outputFormatNames {
   343  		if v == f {
   344  			return true
   345  		}
   346  	}
   347  	return false
   348  }
   349  
   350  // MarshalText encodes an [OutputFormat] as text. It returns error if
   351  // the output format is not valid.
   352  func (f OutputFormat) MarshalText() (text []byte, err error) {
   353  	if !f.IsValid() {
   354  		return nil, ErrInvalidOutputFormat
   355  	}
   356  	return []byte(f.String()), nil
   357  }
   358  
   359  // UnmarshalText decodes an [OutputFormat] text into an [OutputFormat]
   360  // value. It returns error if the provided string does not match any
   361  // known output format.
   362  func (f *OutputFormat) UnmarshalText(text []byte) error {
   363  	format, err := parseOutputFormat(string(text))
   364  	if err != nil {
   365  		return err
   366  	}
   367  	*f = format
   368  	return nil
   369  }
   370  
   371  // Exclusion represents the criteria to exclude a given finding.
   372  type Exclusion struct {
   373  	// Target is a regular expression that matches the name of the
   374  	// affected target.
   375  	Target string `yaml:"target"`
   376  
   377  	// Resource is a regular expression that matches the name of
   378  	// the affected resource.
   379  	Resource string `yaml:"resource"`
   380  
   381  	// Fingerprint defines the context in where the vulnerability
   382  	// has been found. It includes the checktype image, the
   383  	// affected target, the asset type and the checktype options.
   384  	Fingerprint string `yaml:"fingerprint"`
   385  
   386  	// Summary is a regular expression that matches the summary of
   387  	// the vulnerability.
   388  	Summary string `yaml:"summary"`
   389  
   390  	// Description describes the exclusion.
   391  	Description string `yaml:"description"`
   392  }