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 }