diff --git a/cmd/dependabot/internal/cmd/reachability.go b/cmd/dependabot/internal/cmd/reachability.go new file mode 100644 index 00000000..243d8058 --- /dev/null +++ b/cmd/dependabot/internal/cmd/reachability.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "context" + "errors" + "log" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/dependabot/cli/internal/infra" + "github.com/dependabot/cli/internal/model" + "github.com/spf13/cobra" +) + +// executeReachability is a seam so tests can capture the params without Docker, +// mirroring the `test` command's executeTestJob pattern. +var executeReachability = infra.RunReachability + +type ReachabilityFlags struct { + file string + inputDir string + annotations string + codeqlPath string + reachabilityImage string + proxyCertPath string + cache string + extraHosts []string + pullImages bool + timeout time.Duration +} + +var reachabilityCmd = NewReachabilityCommand() + +func init() { + rootCmd.AddCommand(reachabilityCmd) +} + +func NewReachabilityCommand() *cobra.Command { + var flags ReachabilityFlags + + cmd := &cobra.Command{ + Use: "reachability --input-dir --reachability-image [-f ] [flags]", + Short: "[Experimental] Decide which vulnerable dependencies are reachable from first-party code", + Long: heredoc.Doc(` + NOTE: This command is a work in progress. + + It runs the dependabot-reachability-cli as a Dependabot job: it starts + the proxy and a reachability container (networked so dependency fetches + go through the proxy), then runs the CodeQL reachability analysis over a + pre-provided set of inputs. + + The inputs are provided in --input-dir (not fetched here): the target/ + checkout, alerts.json, an optional sbom.json (SBOM-first inventory), and + the annotations feed. Outputs (refined.csv, paths.json) are written back + into --input-dir. + + $ dependabot reachability --input-dir ./reach-run \ + --reachability-image ghcr.io/dependabot/dependabot-reachability \ + -f input.yml + `), + RunE: func(cmd *cobra.Command, args []string) error { + var input model.Input + if flags.file != "" { + in, err := readInputFile(flags.file) + if err != nil { + return err + } + input = *in + } + // A package manager is needed so the proxy configures credential + // injection; default to npm while npm is the only supported ecosystem. + if input.Job.PackageManager == "" { + input.Job.PackageManager = "npm_and_yarn" + } + + if err := executeReachability(infra.ReachabilityParams{ + Job: &input.Job, + Creds: input.Credentials, + ReachabilityImage: flags.reachabilityImage, + ProxyImage: proxyImage, + InputDir: flags.inputDir, + Annotations: flags.annotations, + CodeqlPath: flags.codeqlPath, + PullImages: flags.pullImages, + Timeout: flags.timeout, + ExtraHosts: flags.extraHosts, + ProxyCertPath: flags.proxyCertPath, + CacheDir: flags.cache, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + log.Fatalf("reachability timed out after %s", flags.timeout) + } + log.Fatalf("reachability failure: %v", err) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&flags.file, "file", "f", "", "path to input file (job source + credentials for the proxy)") + cmd.Flags().StringVar(&flags.inputDir, "input-dir", "", "host dir holding the reachability inputs (target/, alerts.json, sbom.json, annotations/)") + cmd.Flags().StringVar(&flags.annotations, "annotations", "annotations", "annotation feed path relative to --input-dir") + cmd.Flags().StringVar(&flags.codeqlPath, "codeql", "", "path to the codeql binary inside the reachability image") + cmd.Flags().StringVar(&flags.reachabilityImage, "reachability-image", "", "container image bundling reach + codeql") + cmd.Flags().StringVar(&flags.proxyCertPath, "proxy-cert", "", "path to a certificate the proxy will trust") + cmd.Flags().StringVar(&flags.cache, "cache", "", "cache import/export directory") + cmd.Flags().StringArrayVar(&flags.extraHosts, "extra-hosts", nil, "Docker extra hosts setting on the proxy") + cmd.Flags().BoolVar(&flags.pullImages, "pull", true, "pull the images if not present") + cmd.Flags().DurationVarP(&flags.timeout, "timeout", "t", 0, "max time to run the reachability check") + + _ = cmd.MarkFlagRequired("input-dir") + _ = cmd.MarkFlagRequired("reachability-image") + + return cmd +} diff --git a/cmd/dependabot/internal/cmd/reachability_test.go b/cmd/dependabot/internal/cmd/reachability_test.go new file mode 100644 index 00000000..63ab0ee3 --- /dev/null +++ b/cmd/dependabot/internal/cmd/reachability_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "testing" + + "github.com/dependabot/cli/internal/infra" +) + +// TestReachabilityCommand verifies the reachability subcommand wires its flags +// into infra.ReachabilityParams, using the executeReachability seam so no Docker +// is needed. +func TestReachabilityCommand(t *testing.T) { + var captured infra.ReachabilityParams + original := executeReachability + executeReachability = func(params infra.ReachabilityParams) error { + captured = params + return nil + } + defer func() { executeReachability = original }() + + cmd := NewReachabilityCommand() + cmd.SetArgs([]string{ + "--input-dir", "/tmp/inputs", + "--reachability-image", "example/reach:latest", + "--annotations", "annotations", + "--codeql", "/opt/codeql/codeql", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("command failed: %v", err) + } + + if captured.InputDir != "/tmp/inputs" { + t.Errorf("InputDir = %q, want /tmp/inputs", captured.InputDir) + } + if captured.ReachabilityImage != "example/reach:latest" { + t.Errorf("ReachabilityImage = %q, want example/reach:latest", captured.ReachabilityImage) + } + if captured.CodeqlPath != "/opt/codeql/codeql" { + t.Errorf("CodeqlPath = %q, want /opt/codeql/codeql", captured.CodeqlPath) + } + if captured.Annotations != "annotations" { + t.Errorf("Annotations = %q, want annotations", captured.Annotations) + } + if captured.Job == nil { + t.Fatal("Job should be set") + } + if captured.Job.PackageManager == "" { + t.Error("Job.PackageManager should default to a value") + } +} + +// TestReachabilityCommandRequiresInputs ensures the required flags are enforced. +func TestReachabilityCommandRequiresInputs(t *testing.T) { + original := executeReachability + executeReachability = func(params infra.ReachabilityParams) error { return nil } + defer func() { executeReachability = original }() + + cmd := NewReachabilityCommand() + cmd.SetArgs([]string{}) // no --input-dir / --reachability-image + if err := cmd.Execute(); err == nil { + t.Fatal("expected an error when required flags are missing") + } +} diff --git a/internal/infra/reachability.go b/internal/infra/reachability.go new file mode 100644 index 00000000..4deb0f31 --- /dev/null +++ b/internal/infra/reachability.go @@ -0,0 +1,313 @@ +package infra + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "os/signal" + "path" + "path/filepath" + "syscall" + "time" + + "github.com/dependabot/cli/internal/model" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + archive "github.com/moby/go-archive" +) + +// guestReachInputDir is where the reachability input set lives inside the +// container. It mirrors `reach run`'s -workdir contract: it holds the target/ +// checkout, alerts.json, an optional sbom.json, and the annotations feed, and +// reach writes its outputs (refined.csv, paths.json) here. The input set is +// copied IN and the outputs copied back OUT (not bind-mounted), so this works +// even when dependabot-cli itself runs inside a container (e.g. `act` or a +// containerized runner) where a host bind mount would not resolve. +const guestReachInputDir = "/home/dependabot/reach-run" + +// ReachabilityParams configures a reachability job. It reuses the proxy and the +// isolated networking of a normal update job - so the reachability-cli's +// dependency fetches are credential-injected by the proxy, and the container +// trusts the proxy's MITM CA (via update-ca-certificates + SSL_CERT_FILE, +// exactly as the updater does) - but runs the dependabot-reachability-cli +// (`reach run`) in place of an ecosystem updater. The reachability image plays +// the "updater" role in the update/graph pattern. +type ReachabilityParams struct { + // Job carries the repo Source + package manager; it drives proxy/credential + // setup (reach itself never sees the credentials). + Job *model.Job + // Creds are the registry credentials the proxy injects into fetches. + Creds []model.Credential + // ReachabilityImage bundles `reach` + the CodeQL CLI + the query pack. + // TODO: publish this image (reach + codeql + node) and set a default. + ReachabilityImage string + // ProxyImage is the proxy container image. + ProxyImage string + // InputDir is the local directory whose contents are copied into the + // container at guestReachInputDir (and whose outputs are copied back). It must + // contain the target/ checkout, alerts.json, and the annotations feed; an + // sbom.json enables the SBOM-first inventory (else target/package-lock.json). + InputDir string + // Annotations is the annotation-feed path RELATIVE to InputDir (e.g. "annotations"). + Annotations string + // CodeqlPath is the path to the codeql binary inside the reachability image. + CodeqlPath string + // PullImages pulls the proxy + reachability images if absent. + PullImages bool + // Timeout optionally bounds the whole run. + Timeout time.Duration + // ExtraHosts adds /etc/hosts entries to the proxy (testing). + ExtraHosts []string + // ProxyCertPath is an extra certificate for the proxy to trust. + ProxyCertPath string + // CacheDir optionally caches proxy requests. + CacheDir string + // ApiUrl is passed to the proxy; reachability has no fake API server, so a + // placeholder is fine. + ApiUrl string +} + +// RunReachability runs a reachability job. It starts the proxy and the +// reachability container on isolated networks (the reachability container on +// no-internet, the proxy bridging to the internet), makes the container trust +// the proxy CA, and runs `reach run` against the bind-mounted input set. reach's +// dependency fetches therefore go through the proxy (credential injection for +// private registries) with the proxy CA trusted - the same isolation the +// updater gets. Outputs land in InputDir via the bind mount. +func RunReachability(params ReachabilityParams) (err error) { + if params.Job == nil { + return fmt.Errorf("job is required (repo source + credentials for the proxy)") + } + if params.ReachabilityImage == "" { + return fmt.Errorf("reachability image is required") + } + if params.InputDir == "" { + return fmt.Errorf("input dir is required (target/, alerts.json, sbom.json, annotations/)") + } + // Record the job type; the reachability image plays the updater role. + params.Job.Command = model.ReachabilityCommand + + absInput, err := filepath.Abs(params.InputDir) + if err != nil { + return fmt.Errorf("input dir: %w", err) + } + + var ctx context.Context + var cancel func() + if params.Timeout > 0 { + ctx, cancel = context.WithTimeout(context.Background(), params.Timeout) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + defer cancel() + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-signals + cancel() + }() + + // Reuse the proxy machinery by mapping onto a RunParams; the credential + // expansion + proxy setup are then identical to a normal job. + rp := &RunParams{ + Job: params.Job, + Creds: params.Creds, + ProxyImage: firstNonEmpty(params.ProxyImage, ProxyImageName), + ProxyCertPath: params.ProxyCertPath, + CacheDir: params.CacheDir, + ExtraHosts: params.ExtraHosts, + ApiUrl: firstNonEmpty(params.ApiUrl, "http://host.docker.internal:0"), + } + for _, cred := range rp.Creds { + for key, value := range cred { + if s, ok := value.(string); ok { + cred[key] = os.ExpandEnv(s) + } + } + } + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + if params.PullImages { + if err = pullImage(ctx, cli, rp.ProxyImage); err != nil { + return err + } + if err = pullImage(ctx, cli, params.ReachabilityImage); err != nil { + return err + } + } + + networks, err := NewNetworks(ctx, cli) + if err != nil { + return fmt.Errorf("failed to create networks: %w", err) + } + defer networks.Close() + + prox, err := NewProxy(ctx, cli, rp, networks) + if err != nil { + return err + } + defer func() { + if e := prox.Close(); e != nil { + err = e + } + }() + go prox.TailLogs(ctx, cli) + + // Start the reachability container: /bin/sh + Tty so it stays up, connected + // only to the no-internet network so every fetch egresses through the proxy. + // The input set is copied IN and the outputs OUT (not bind-mounted), so this + // works even when dependabot-cli runs inside a container (act / a + // containerized runner), where a host bind mount would not resolve. + containerCfg := &container.Config{ + User: dependabot, + Image: params.ReachabilityImage, + Cmd: []string{"/bin/sh"}, + Tty: true, + } + netCfg := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + networks.noInternetName: {NetworkID: networks.NoInternet.ID}, + }, + } + created, err := cli.ContainerCreate(ctx, containerCfg, nil, netCfg, nil, "") + if err != nil { + return fmt.Errorf("failed to create reachability container: %w", err) + } + reach := &Updater{cli: cli, containerID: created.ID} + defer func() { + if e := reach.Close(); e != nil { + err = e + } + }() + + // Copy the proxy CA into the container so update-ca-certificates trusts it + // (same cert the updater path installs). + if t, terr := tarball(dbotCert, prox.ca.Cert); terr != nil { + return fmt.Errorf("failed to create cert tarball: %w", terr) + } else if err = cli.CopyToContainer(ctx, created.ID, "/", t, container.CopyToContainerOptions{}); err != nil { + return fmt.Errorf("failed to copy proxy CA to container: %w", err) + } + + if err = cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("failed to start reachability container: %w", err) + } + + // Copy the input set into the container (owned by dependabot so reach can + // write its outputs alongside them). + if err = copyDirIntoContainer(ctx, cli, reach, absInput, guestReachInputDir); err != nil { + return err + } + + if err = reach.RunCmd(ctx, "update-ca-certificates", root); err != nil { + return err + } + + // userEnv sets http(s)_proxy= and SSL_CERT_FILE, so reach's registry + // fetches route through the proxy with the proxy CA trusted - no extra cert + // handling needed. reachAPIEnv adds the token (+ GHES API URL) so reach can + // fetch its own control-plane inputs (alerts, SBOM) through the proxy. + env := userEnv(prox.url, rp.ApiUrl, rp.Job, reachAPIEnv(params.Job)) + if err = reach.RunCmd(ctx, reachRunCommand(params), dependabot, env...); err != nil { + return err + } + if reach.ExitCode != nil && *reach.ExitCode != 0 { + return fmt.Errorf("reachability run exited with code %d", *reach.ExitCode) + } + + // Copy the outputs back out to the input dir so the caller can read them. + // refined.csv is required; paths.json / verdicts.csv are best-effort (paths + // only exists when something was reachable). + if err = copyOutFile(ctx, cli, created.ID, path.Join(guestReachInputDir, "refined.csv"), filepath.Join(absInput, "refined.csv")); err != nil { + return fmt.Errorf("copy out refined.csv: %w", err) + } + for _, name := range []string{"paths.json", "verdicts.csv"} { + _ = copyOutFile(ctx, cli, created.ID, path.Join(guestReachInputDir, name), filepath.Join(absInput, name)) + } + return nil +} + +// copyDirIntoContainer copies the contents of localDir into containerDir inside +// the running container and chowns them to the dependabot user, so reach (which +// runs as dependabot) can read the inputs and write its outputs there. Modeled +// on dependabot-cli's putCloneDir, minus the git-repo initialization. +func copyDirIntoContainer(ctx context.Context, cli *client.Client, u *Updater, localDir, containerDir string) error { + if err := u.RunCmd(ctx, "mkdir -p "+containerDir, dependabot); err != nil { + return fmt.Errorf("create %s in container: %w", containerDir, err) + } + r, err := archive.TarWithOptions(localDir, &archive.TarOptions{}) + if err != nil { + return fmt.Errorf("tar %s: %w", localDir, err) + } + if err := cli.CopyToContainer(ctx, u.containerID, containerDir, r, container.CopyToContainerOptions{}); err != nil { + return fmt.Errorf("copy inputs into container: %w", err) + } + if err := u.RunCmd(ctx, "chown -R dependabot "+containerDir, root); err != nil { + return fmt.Errorf("chown %s in container: %w", containerDir, err) + } + return nil +} + +// copyOutFile copies a single file out of the container to dst on the local +// filesystem (CopyFromContainer returns a tar stream with the one entry). +func copyOutFile(ctx context.Context, cli *client.Client, containerID, src, dst string) error { + reader, _, err := cli.CopyFromContainer(ctx, containerID, src) + if err != nil { + return err + } + defer reader.Close() + tr := tar.NewReader(reader) + if _, err := tr.Next(); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, io.LimitReader(tr, 1<<30)) //nolint:gosec // outputs are small CSV/JSON + return err +} + +// reachRunCommand builds the `reach run` invocation executed inside the +// reachability container. Inputs are read from the copied-in input dir; the +// annotation feed and the codeql binary are located from the params. +func reachRunCommand(params ReachabilityParams) string { + annotations := path.Join(guestReachInputDir, firstNonEmpty(params.Annotations, "annotations")) + cmd := fmt.Sprintf("reach run -workdir %s -annotations %s", guestReachInputDir, annotations) + // When the job has a repo, let reach fetch the alerts + SBOM itself (it does + // so through the proxy). A caller that pre-staged alerts.json / sbom.json in + // the input dir still wins: reach only fetches what is missing. + if params.Job != nil && params.Job.Source.Repo != "" { + cmd += " -repo " + params.Job.Source.Repo + } + if params.CodeqlPath != "" { + cmd += " -codeql " + params.CodeqlPath + } + return cmd +} + +// reachAPIEnv returns the extra container env that lets reach reach the GitHub +// API for its own input fetching: a token (so the request authenticates) and, +// for GHES, the API base URL derived from the job source. The requests still +// egress through the proxy; reach carries the token so no proxy-side credential +// injection is required for the control-plane calls. +func reachAPIEnv(job *model.Job) []string { + var env []string + if tok := firstNonEmpty( + os.Getenv("GH_TOKEN"), + os.Getenv("GITHUB_TOKEN"), + os.Getenv("LOCAL_GITHUB_ACCESS_TOKEN"), + ); tok != "" { + env = append(env, "GH_TOKEN="+tok) + } + if job != nil && job.Source.APIEndpoint != nil && *job.Source.APIEndpoint != "" { + env = append(env, "GITHUB_API_URL="+*job.Source.APIEndpoint) + } + return env +} diff --git a/internal/infra/reachability_test.go b/internal/infra/reachability_test.go new file mode 100644 index 00000000..86eaf7ef --- /dev/null +++ b/internal/infra/reachability_test.go @@ -0,0 +1,58 @@ +package infra + +import ( + "strings" + "testing" + + "github.com/dependabot/cli/internal/model" +) + +// reachRunCommand should add -repo when the job has a source repo, so reach +// fetches the alerts + SBOM itself; and omit it otherwise (pre-staged inputs). +func TestReachRunCommandRepo(t *testing.T) { + withRepo := reachRunCommand(ReachabilityParams{ + Job: &model.Job{Source: model.Source{Repo: "octo/repo"}}, + CodeqlPath: "/opt/codeql/codeql", + }) + if !strings.Contains(withRepo, "-repo octo/repo") { + t.Errorf("expected -repo octo/repo in %q", withRepo) + } + if !strings.Contains(withRepo, "-codeql /opt/codeql/codeql") { + t.Errorf("expected -codeql in %q", withRepo) + } + + noRepo := reachRunCommand(ReachabilityParams{Job: &model.Job{}}) + if strings.Contains(noRepo, "-repo") { + t.Errorf("did not expect -repo in %q", noRepo) + } +} + +// reachAPIEnv should surface a token from the environment as GH_TOKEN and, for +// GHES, the API endpoint as GITHUB_API_URL. +func TestReachAPIEnv(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "secret") + t.Setenv("LOCAL_GITHUB_ACCESS_TOKEN", "") + + ghes := "https://ghe.example.com/api/v3" + env := reachAPIEnv(&model.Job{Source: model.Source{APIEndpoint: &ghes}}) + + joined := strings.Join(env, "\n") + if !strings.Contains(joined, "GH_TOKEN=secret") { + t.Errorf("expected GH_TOKEN=secret in %v", env) + } + if !strings.Contains(joined, "GITHUB_API_URL="+ghes) { + t.Errorf("expected GITHUB_API_URL in %v", env) + } +} + +// With no token in the environment and no GHES endpoint, reachAPIEnv is empty +// (reach falls back to unauthenticated / the public API base). +func TestReachAPIEnvEmpty(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("LOCAL_GITHUB_ACCESS_TOKEN", "") + if env := reachAPIEnv(&model.Job{}); len(env) != 0 { + t.Errorf("expected empty env, got %v", env) + } +} diff --git a/internal/model/smoke.go b/internal/model/smoke.go index 42afc78e..3b80804f 100644 --- a/internal/model/smoke.go +++ b/internal/model/smoke.go @@ -3,11 +3,12 @@ package model type RunCommand string const ( - UpdateFilesCommand RunCommand = "update" - VersionCommand RunCommand = "version" - RecreateCommand RunCommand = "recreate" - SecurityCommand RunCommand = "security" - UpdateGraphCommand RunCommand = "graph" + UpdateFilesCommand RunCommand = "update" + VersionCommand RunCommand = "version" + RecreateCommand RunCommand = "recreate" + SecurityCommand RunCommand = "security" + UpdateGraphCommand RunCommand = "graph" + ReachabilityCommand RunCommand = "reachability" ) // SmokeTest is a way to test a job by asserting the outputs.