github.com/adevinta/lava@v0.7.2/cmd/lava/internal/run/run.go (about) 1 // Copyright 2024 Adevinta 2 3 // Package run implements the run command. 4 package run 5 6 import ( 7 "context" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io/fs" 12 "log/slog" 13 "os" 14 "os/exec" 15 "path/filepath" 16 "runtime/debug" 17 "time" 18 19 agentconfig "github.com/adevinta/vulcan-agent/config" 20 checkcatalog "github.com/adevinta/vulcan-check-catalog/pkg/model" 21 types "github.com/adevinta/vulcan-types" 22 "github.com/docker/docker/api/types/filters" 23 "github.com/docker/docker/api/types/image" 24 25 "github.com/adevinta/lava/cmd/lava/internal/base" 26 "github.com/adevinta/lava/internal/assettypes" 27 "github.com/adevinta/lava/internal/checktypes" 28 "github.com/adevinta/lava/internal/config" 29 "github.com/adevinta/lava/internal/containers" 30 "github.com/adevinta/lava/internal/engine" 31 "github.com/adevinta/lava/internal/metrics" 32 "github.com/adevinta/lava/internal/report" 33 ) 34 35 // CmdRun represents the run command. 36 var CmdRun = &base.Command{ 37 UsageLine: "run [flags] checktype target", 38 Short: "run scan", 39 Long: ` 40 Run a checktype against a target. 41 42 Run accepts two arguments: the checktype to run and the target of the 43 scan. The checktype is a container image reference (e.g. 44 "vulcansec/vulcan-trivy:edge") or a path pointing to a directory with 45 the source code of a checktype. The target is any of the targets 46 supported by the -type flag. 47 48 The -type flag determines the type of the provided target. Valid 49 values are "AWSAccount", "DockerImage", "GitRepository", "IP", 50 "IPRange", "DomainName", "Hostname", "WebAddress" and "Path". If not 51 specified, "Path" is used. For more details, use "lava help 52 lava.yaml". 53 54 The -timeout flag sets the timeout of the checktype execution. This 55 flag accepts a value acceptable to time.ParseDuration. If not 56 specified, "600s" is used. 57 58 The -opt and -optfile flags specify the checktype options. The -opt 59 flag accepts a string with the options. The -optfile flag accepts a 60 path to an options file. The options must be provided in JSON format 61 and follow the checktype manifest. 62 63 The -var flag sets the environment variables passed to the 64 checktype. The environment variables must be provided using the format 65 "name[=value]". If there is no equal sign, the value of the variable 66 is got from the environment. This flag can be specified multiple 67 times. 68 69 The -pull flag determines the pull policy for container images. Valid 70 values are "Always" (always download the image), "IfNotPresent" (pull 71 the image if it not present in the local cache) and "Never" (never 72 pull the image). If not specified, "IfNotPresent" is used. If the 73 checktype is a path, only "IfNotPresent" and "Never" are allowed. 74 75 The -registry flag specifies the container registry. If the registry 76 requires authentication, the credentials are provided using the -user 77 flag. The -user flag accepts the credentials with the format 78 "username[:[password]]". The username and password are split around 79 the first instance of the colon. So the username cannot contain a 80 colon. If there is no colon, the password is read from the standard 81 input. 82 83 The -severity flag determines the minimum severity required to report 84 a finding. Valid values are "critical", "high", "medium", "low" and 85 "info". If not specified, "high" is used. 86 87 The -o flag specifies the output file to write the results of the 88 scan. If not specified, the standard output is used. The format of the 89 output is defined by the -fmt flag. The -fmt flag accepts the values 90 "human" for human-readable output and "json" for JSON-encoded 91 output. If not specified, "human" is used. 92 93 The -metrics flag specifies the file to write the security, 94 operational and configuration metrics of the scan. For more details, 95 use "lava help metrics". 96 97 The -log flag defines the logging level. Valid values are "debug", 98 "info", "warn" and "error". If not specified, "info" is used. 99 100 # Path checktype 101 102 When the specified checktype is a path that points to a directory, 103 Lava assumes that the directory contains the source code of the 104 checktype. 105 106 The directory must contains at least the following files: 107 108 - Dockerfile 109 - Go source code (*.go) 110 111 Lava will build the Go source code and then it will create a Docker 112 image based on the Dockerfile file found in the directory. The 113 reference of the generated image has the format "name:lava-run". Where 114 name is the name of the directory pointed by the specified path. If 115 the path is "/", the string "lava-checktype" is used. If the path is 116 ".", the name of the current directory is used. 117 118 Thus, the following command: 119 120 lava run /path/to/vulcan-trivy . 121 122 would generate a Docker image with the reference 123 "vulcan-trivy:lava-run". 124 125 Finally, the generated Docker image is used as checktype to run a scan 126 against the provided target with the specified options. 127 128 This mode requires a working Go toolchain in PATH. 129 130 # Examples 131 132 Run the checktype "vulcansec/vulcan-trivy:edge" against the current 133 directory: 134 135 lava run vulcansec/vulcan-trivy:edge . 136 137 Run the checktype "vulcansec/vulcan-trivy:edge" against the current 138 directory with the options stored in the "options.json" file: 139 140 lava run -optfile=options.json vulcansec/vulcan-trivy:edge . 141 142 Build and run the checktype in the path "/path/to/vulcan-trivy" 143 against the current directory: 144 145 lava run /path/to/vulcan-trivy . 146 147 Run the checktype "vulcansec/vulcan-nuclei:edge" against the remote 148 "WebAddress" target "https://example.com": 149 150 lava run -type=WebAddress vulcansec/vulcan-nuclei:edge https://example.com 151 152 Run the checktype "vulcansec/vulcan-nuclei:edge" against the local 153 "WebAddress" target "http://localhost:1234". Write the results in JSON 154 format to the "output.json" file. Also write security, operational and 155 configuration metrics to the "metrics.json" file: 156 157 lava run -o output.json -fmt=json -metrics=metrics.json \ 158 -type=WebAddress vulcansec/vulcan-nuclei:edge http://localhost:1234 159 `} 160 161 // Command-line flags. 162 var ( 163 runType typeFlag = "Path" // -type flag 164 runTimeout time.Duration // -timeout flag 165 runOpt string // -opt flag 166 runOptfile string // -optfile flag 167 runVar varFlag // -var flag 168 runPull agentconfig.PullPolicy // -pull flag 169 runRegistry string // -registry flag 170 runUser userFlag // -user flag 171 runSeverity config.Severity // -severity flag 172 runO string // -o flag 173 runFmt config.OutputFormat // -fmt flag 174 runMetrics string // -metrics flag 175 runLog slog.Level // -log flag 176 ) 177 178 func init() { 179 CmdRun.Run = runRun // Break initialization cycle. 180 } 181 182 // osExit is used by tests to capture the exit code. 183 var osExit = os.Exit 184 185 // runRun is the entry point of the CmdRun command. 186 func runRun(args []string) error { 187 exitCode, err := run(args) 188 if err != nil { 189 return err 190 } 191 osExit(exitCode) 192 return nil 193 } 194 195 // run contains the logic of the [CmdRun] command. It is wrapped by 196 // the run function, so the deferred functions can be executed before 197 // calling [os.Exit]. It returns the exit code that must be passed to 198 // [os.Exit]. 199 func run(args []string) (int, error) { 200 if len(args) != 2 { 201 return 0, errors.New("invalid number of arguments") 202 } 203 checktype := args[0] 204 targetIdent := args[1] 205 206 startTime := time.Now() 207 metrics.Collect("start_time", startTime) 208 209 base.LogLevel.Set(runLog) 210 211 bi, ok := debug.ReadBuildInfo() 212 if !ok { 213 return 0, errors.New("could not read build info") 214 } 215 metrics.Collect("lava_version", bi.Main.Version) 216 217 rep, err := engineRun(targetIdent, checktype) 218 if err != nil { 219 return 0, fmt.Errorf("engine run: %w", err) 220 } 221 222 exitCode, err := writeOutputs(rep) 223 if err != nil { 224 return 0, fmt.Errorf("write report: %w", err) 225 } 226 227 metrics.Collect("exit_code", exitCode) 228 metrics.Collect("duration", time.Since(startTime).Seconds()) 229 230 return int(exitCode), nil 231 } 232 233 // engineRun runs a check against the specified targetIdent with the 234 // specified checktype. It gets the configuration from the provided 235 // flags. 236 func engineRun(targetIdent string, checktype string) (engine.Report, error) { 237 target, err := mkTarget(targetIdent) 238 if err != nil { 239 return nil, fmt.Errorf("generate target: %w", err) 240 } 241 metrics.Collect("targets", []config.Target{target}) 242 243 agentConfig := mkAgentConfig() 244 info, err := os.Stat(checktype) 245 switch { 246 case err != nil && !errors.Is(err, fs.ErrNotExist): 247 return nil, err 248 case err == nil && info.IsDir(): 249 if agentConfig.PullPolicy != agentconfig.PullPolicyIfNotPresent && agentConfig.PullPolicy != agentconfig.PullPolicyNever { 250 return nil, errors.New("path checktypes only allow IfNotPresent and Never pull policies") 251 } 252 253 ct, err := buildChecktype(checktype) 254 if err != nil { 255 return nil, fmt.Errorf("build checktype: %w", err) 256 } 257 checktype = ct 258 } 259 260 checktypeCatalog := mkChecktypeCatalog(checktype) 261 eng, err := engine.NewWithCatalog(agentConfig, checktypeCatalog) 262 if err != nil { 263 return nil, fmt.Errorf("engine initialization: %w", err) 264 } 265 defer eng.Close() 266 267 rep, err := eng.Run([]config.Target{target}) 268 if err != nil { 269 return nil, fmt.Errorf("engine run: %w", err) 270 } 271 return rep, nil 272 } 273 274 // buildChecktype builds the checktype in path. It returns the 275 // reference of the new Docker image. 276 func buildChecktype(path string) (string, error) { 277 slog.Info("building Go source code", "path", path) 278 279 cmd := exec.Command("go", "build") 280 cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux") 281 cmd.Dir = path 282 cmd.Stderr = os.Stderr 283 if err := cmd.Run(); err != nil { 284 return "", fmt.Errorf("go build: %w", err) 285 } 286 287 abs, err := filepath.Abs(path) 288 if err != nil { 289 return "", fmt.Errorf("absolute path: %w", err) 290 } 291 dirname := filepath.Base(abs) 292 if dirname == "/" { 293 dirname = "lava-checktype" 294 } 295 296 rt, err := containers.GetenvRuntime() 297 if err != nil { 298 return "", fmt.Errorf("get env runtime: %w", err) 299 } 300 301 cli, err := containers.NewDockerdClient(rt) 302 if err != nil { 303 return "", fmt.Errorf("new dockerd client: %w", err) 304 } 305 306 ref := dirname + ":lava-run" 307 308 summ, err := cli.ImageList(context.Background(), image.ListOptions{ 309 Filters: filters.NewArgs(filters.Arg("reference", ref)), 310 }) 311 if err != nil { 312 return "", fmt.Errorf("image list: %w", err) 313 } 314 315 slog.Info("building Docker image", "ref", ref) 316 317 newID, err := cli.ImageBuild(context.Background(), path, "Dockerfile", ref) 318 if err != nil { 319 return "", fmt.Errorf("image build: %w", err) 320 } 321 322 switch n := len(summ); n { 323 case 0: 324 // No image found. Nothing to do. 325 case 1: 326 if newID == summ[0].ID { 327 // The new image has the same ID. So, do not 328 // delete it. 329 break 330 } 331 rmOpts := image.RemoveOptions{Force: true, PruneChildren: true} 332 if _, err := cli.ImageRemove(context.Background(), summ[0].ID, rmOpts); err != nil { 333 return "", fmt.Errorf("image remove: %w", err) 334 } 335 default: 336 return "", fmt.Errorf("image list: unexpected number of images: %v", n) 337 } 338 339 return ref, nil 340 } 341 342 // mkTarget generates a target from the provided flags and positional 343 // arguments. 344 func mkTarget(targetIdent string) (target config.Target, err error) { 345 if runOpt != "" && runOptfile != "" { 346 return config.Target{}, errors.New("-opt and -optfile cannot be set simultaneously") 347 } 348 349 optbytes := []byte(runOpt) 350 if runOptfile != "" { 351 if optbytes, err = os.ReadFile(runOptfile); err != nil { 352 return config.Target{}, fmt.Errorf("read file: %w", err) 353 } 354 } 355 356 var opts map[string]any 357 if len(optbytes) > 0 { 358 if err := json.Unmarshal(optbytes, &opts); err != nil { 359 return config.Target{}, fmt.Errorf("JSON unmarshal: %w", err) 360 } 361 } 362 363 target = config.Target{ 364 Identifier: targetIdent, 365 AssetType: types.AssetType(runType), 366 Options: opts, 367 } 368 return target, nil 369 } 370 371 // mkAgentConfig generates an agent configuration from the provided 372 // flags. 373 func mkAgentConfig() config.AgentConfig { 374 var auths []config.RegistryAuth 375 if runRegistry != "" { 376 auths = []config.RegistryAuth{ 377 { 378 Server: runRegistry, 379 Username: runUser.Username, 380 Password: runUser.Password, 381 }, 382 } 383 } 384 385 return config.AgentConfig{ 386 PullPolicy: runPull, 387 Vars: runVar, 388 RegistryAuths: auths, 389 } 390 } 391 392 // mkChecktypeCatalog generates a checktype catalog from the provided 393 // flags and positional arguments. 394 func mkChecktypeCatalog(checktype string) checktypes.Catalog { 395 vulcanAssetType := assettypes.ToVulcan(types.AssetType(runType)) 396 ct := checkcatalog.Checktype{ 397 Name: checktype, 398 Image: checktype, 399 Timeout: int(runTimeout.Seconds()), 400 Assets: []string{vulcanAssetType.String()}, 401 } 402 return checktypes.Catalog{checktype: ct} 403 } 404 405 // writeOutputs writes the provided report and the metrics file. It 406 // returns the exit code of the run command based on the 407 // report. writeOutputs gets the configuration from the provided 408 // flags. 409 func writeOutputs(rep engine.Report) (report.ExitCode, error) { 410 reportConfig := config.ReportConfig{ 411 Severity: runSeverity, 412 Format: runFmt, 413 OutputFile: runO, 414 Metrics: runMetrics, 415 } 416 metrics.Collect("severity", reportConfig.Severity) 417 418 rw, err := report.NewWriter(reportConfig) 419 if err != nil { 420 return 0, fmt.Errorf("new writer: %w", err) 421 } 422 defer rw.Close() 423 424 exitCode, err := rw.Write(rep) 425 if err != nil { 426 return 0, fmt.Errorf("render report: %w", err) 427 } 428 429 if reportConfig.Metrics != "" { 430 if err = metrics.WriteFile(reportConfig.Metrics); err != nil { 431 return 0, fmt.Errorf("write metrics: %w", err) 432 } 433 } 434 435 return exitCode, err 436 }