From 84af8904c62faf5c808ef325d13fe7e09e150c1a Mon Sep 17 00:00:00 2001 From: Nish Sinha Date: Mon, 27 Jul 2026 18:08:33 -0400 Subject: [PATCH 1/3] Add reachability job type Adds a `dependabot reachability` subcommand and `infra.RunReachability` that runs the dependabot-reachability-cli as a Dependabot job: it starts the proxy and a reachability container (the reachability image playing the updater role), networks them so dependency fetches go through the proxy, trusts the proxy CA, and runs `reach run` over a pre-provided input set. Follows the update/graph command pattern; adds model.ReachabilityCommand and unit tests for the flag wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9cbd398-a2cf-426c-83a7-e42d5f9cf684 --- cmd/dependabot/internal/cmd/reachability.go | 115 +++++++++ .../internal/cmd/reachability_test.go | 63 +++++ internal/infra/reachability.go | 228 ++++++++++++++++++ internal/model/smoke.go | 11 +- 4 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 cmd/dependabot/internal/cmd/reachability.go create mode 100644 cmd/dependabot/internal/cmd/reachability_test.go create mode 100644 internal/infra/reachability.go 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..c1b60d1b --- /dev/null +++ b/internal/infra/reachability.go @@ -0,0 +1,228 @@ +package infra + +import ( + "context" + "fmt" + "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/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" +) + +// guestReachInputDir is where the reachability input set is bind-mounted 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) back here - so +// the bind mount surfaces them on the host. +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 host directory bind-mounted at guestReachInputDir. 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, the input + // set bind-mounted, connected only to the no-internet network so every fetch + // egresses through the proxy. + containerCfg := &container.Config{ + User: dependabot, + Image: params.ReachabilityImage, + Cmd: []string{"/bin/sh"}, + Tty: true, + } + hostCfg := &container.HostConfig{ + Mounts: []mount.Mount{{ + Type: mount.TypeBind, + Source: absInput, + Target: guestReachInputDir, + }}, + } + netCfg := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + networks.noInternetName: {NetworkID: networks.NoInternet.ID}, + }, + } + created, err := cli.ContainerCreate(ctx, containerCfg, hostCfg, 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) + } + + 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. + env := userEnv(prox.url, rp.ApiUrl, rp.Job, nil) + 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) + } + return nil +} + +// reachRunCommand builds the `reach run` invocation executed inside the +// reachability container. Inputs are read from the bind-mounted 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) + if params.CodeqlPath != "" { + cmd += " -codeql " + params.CodeqlPath + } + return cmd +} 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. From d18f6b277d7ce6adb58eede1cf64fb9cb7c7a778 Mon Sep 17 00:00:00 2001 From: Nish Sinha Date: Mon, 27 Jul 2026 22:12:57 -0400 Subject: [PATCH 2/3] reachability: copy inputs in / outputs out instead of bind-mounting Bind-mounting the input dir fails when dependabot-cli itself runs inside a container (e.g. `act`, or a containerized runner), because the host daemon cannot resolve the runner-container path. Copy the input set into the container (chowned to dependabot) and copy the outputs back out, matching the updater's putCloneDir approach. Works under act and on real runners alike. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9cbd398-a2cf-426c-83a7-e42d5f9cf684 --- internal/infra/reachability.go | 96 +++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/internal/infra/reachability.go b/internal/infra/reachability.go index c1b60d1b..fa34ce5c 100644 --- a/internal/infra/reachability.go +++ b/internal/infra/reachability.go @@ -1,8 +1,10 @@ package infra import ( + "archive/tar" "context" "fmt" + "io" "os" "os/signal" "path" @@ -12,16 +14,18 @@ import ( "github.com/dependabot/cli/internal/model" "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/mount" "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 is bind-mounted 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) back here - so -// the bind mount surfaces them on the host. +// 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 @@ -42,7 +46,8 @@ type ReachabilityParams struct { ReachabilityImage string // ProxyImage is the proxy container image. ProxyImage string - // InputDir is the host directory bind-mounted at guestReachInputDir. It must + // 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 @@ -154,28 +159,23 @@ func RunReachability(params ReachabilityParams) (err error) { }() go prox.TailLogs(ctx, cli) - // Start the reachability container: /bin/sh + Tty so it stays up, the input - // set bind-mounted, connected only to the no-internet network so every fetch - // egresses through the proxy. + // 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, } - hostCfg := &container.HostConfig{ - Mounts: []mount.Mount{{ - Type: mount.TypeBind, - Source: absInput, - Target: guestReachInputDir, - }}, - } netCfg := &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ networks.noInternetName: {NetworkID: networks.NoInternet.ID}, }, } - created, err := cli.ContainerCreate(ctx, containerCfg, hostCfg, netCfg, nil, "") + created, err := cli.ContainerCreate(ctx, containerCfg, nil, netCfg, nil, "") if err != nil { return fmt.Errorf("failed to create reachability container: %w", err) } @@ -198,6 +198,12 @@ func RunReachability(params ReachabilityParams) (err error) { 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 } @@ -212,11 +218,63 @@ func RunReachability(params ReachabilityParams) (err error) { 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 bind-mounted input dir; 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")) From 6a48b759ad522c2bc249d34a947f7d8d2eb5de04 Mon Sep 17 00:00:00 2001 From: Nish Sinha Date: Thu, 30 Jul 2026 02:18:22 -0400 Subject: [PATCH 3/3] reachability: let reach fetch its own inputs (pass -repo + token through) The reachability-cli should gather its own inputs rather than the Action staging them. reachRunCommand now adds `-repo ` so `reach run` fetches the target's Dependabot alerts and dependency-graph SBOM itself, and reachAPIEnv surfaces a token (+ GHES API URL) into the container so those calls authenticate. In CI the calls egress through the proxy, whose github_api handler injects the github.com git_source credential for api.github.com - so providing that credential is enough and reach need not see the token. A caller that pre-stages alerts.json / sbom.json still wins: reach only fetches what is missing (the "run anywhere" property). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9cbd398-a2cf-426c-83a7-e42d5f9cf684 --- internal/infra/reachability.go | 31 ++++++++++++++- internal/infra/reachability_test.go | 58 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 internal/infra/reachability_test.go diff --git a/internal/infra/reachability.go b/internal/infra/reachability.go index fa34ce5c..4deb0f31 100644 --- a/internal/infra/reachability.go +++ b/internal/infra/reachability.go @@ -210,8 +210,9 @@ func RunReachability(params ReachabilityParams) (err error) { // 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. - env := userEnv(prox.url, rp.ApiUrl, rp.Job, nil) + // 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 } @@ -279,8 +280,34 @@ func copyOutFile(ctx context.Context, cli *client.Client, containerID, src, dst 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) + } +}