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  }