Skip to content

Commit 89a8d70

Browse files
NishnhaCopilot
andcommitted
resolve private graph credentials without backend callbacks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 513deda8-5d8c-4197-a6bf-a574002242f9
1 parent e166a3e commit 89a8d70

13 files changed

Lines changed: 729 additions & 20 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ jobs:
3030
# because any leftover state will generally cause the second run to fail.
3131
run: go test -shuffle=on -count=2 -race -cover -timeout=5m ./...
3232

33+
- name: Test private graph credentials
34+
run: go test ./internal/infra -run '^TestGraphPrivateCredentials$' -count=1 -timeout=6m
35+
env:
36+
DEPENDABOT_GRAPH_INTEGRATION: '1'
37+
3338
lint:
3439
runs-on: ubuntu-latest
3540
steps:

‎README.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,57 @@ and the CLI will pass that token to the proxy
9696
to authenticate API requests to GitHub
9797
(for example, to access private repositories or packages).
9898

99+
### `dependabot graph`
100+
101+
Capture a graph from an existing checkout without creating a backend job:
102+
103+
```shell
104+
dependabot graph --file graph-job.yml --local ./repo --commit "$SHA" --output graph.yml
105+
```
106+
107+
The graph command runs the normal updater and credential-injecting proxy.
108+
`--local` replaces repository fetching, not the proxy. The output is a captured
109+
scenario containing dependency-submission callbacks.
110+
111+
For local private dependencies, supply a separate credentials file:
112+
113+
```shell
114+
dependabot graph --file graph-job.yml --local ./repo \
115+
--credentials-file registry-credentials.yml --output graph.yml
116+
```
117+
118+
```yaml
119+
# registry-credentials.yml
120+
credentials:
121+
- type: npm_registry
122+
registry: https://registry.example.com
123+
token: $NPM_TOKEN
124+
```
125+
126+
The file replaces the job's credentials. Environment references are expanded
127+
for the proxy, and the credentials array is omitted from the output scenario.
128+
Keep credential files outside version control.
129+
130+
For an existing hosted job, the command can retrieve authorized credentials
131+
without fetching or executing that job's details:
132+
133+
```shell
134+
dependabot graph --file graph-job.yml --local ./repo \
135+
--credentials-url "$DEPENDABOT_API_URL/update_jobs/$DEPENDABOT_JOB_ID/credentials" \
136+
--proxy-api-url "$DEPENDABOT_API_URL" --output graph.yml
137+
```
138+
139+
Supply the opaque credentials token through `DEPENDABOT_CREDENTIALS_TOKEN`.
140+
`--proxy-api-url` also requires `JOB_TOKEN`; it lets the proxy request just-in-time
141+
private Git access while graph-result callbacks still go to the local capture
142+
server. Do not pass `--api-url` in this mode: that option sends updater callbacks
143+
to the backend instead of capturing them. Hosted credentials are already resolved
144+
and are neither expanded as environment references nor recorded in the scenario.
145+
`--credentials-url` and `--credentials-file` are mutually exclusive.
146+
147+
Credential retrieval does not select registry policy. The caller must use an
148+
endpoint authorized for the graph's repository, branch, and manifest directories.
149+
99150
### Job description file
100151

101152
The command-line interface for the `update` subcommand

‎cmd/dependabot/internal/cmd/graph.go‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,29 @@ package cmd
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
76
"io"
8-
"log"
97
"os"
8+
"time"
109

1110
"github.com/MakeNowJust/heredoc"
11+
"github.com/dependabot/cli/internal/credentials"
1212
"github.com/dependabot/cli/internal/infra"
1313
"github.com/dependabot/cli/internal/model"
1414
"github.com/spf13/cobra"
1515
)
1616

1717
var graphCmd = NewGraphCommand()
18+
var executeGraphJob = infra.Run
19+
var fetchGraphCredentials = (credentials.Client{}).Fetch
1820

1921
func init() {
2022
rootCmd.AddCommand(graphCmd)
2123
}
2224

2325
func NewGraphCommand() *cobra.Command {
2426
var flags UpdateFlags
27+
var credentialsURL, credentialsFile, proxyAPIURL string
2528

2629
cmd := &cobra.Command{
2730
Use: "graph [<package_manager> <repo> | -f <input.yml>] [flags]",
@@ -37,22 +40,22 @@ func NewGraphCommand() *cobra.Command {
3740
$ dependabot graph -f input.yml
3841
`),
3942
RunE: func(cmd *cobra.Command, args []string) error {
40-
var outFile *os.File
41-
if flags.output != "" {
42-
var err error
43-
outFile, err = os.Create(flags.output)
44-
if err != nil {
45-
return fmt.Errorf("failed to create output file: %w", err)
46-
}
47-
defer outFile.Close()
48-
}
49-
5043
input, err := extractInput(cmd, &flags)
5144
if err != nil {
5245
return err
5346
}
5447

5548
processInput(input, &flags)
49+
started := time.Now()
50+
ctx := cmd.Context()
51+
if flags.timeout > 0 {
52+
var cancel context.CancelFunc
53+
ctx, cancel = context.WithTimeout(ctx, flags.timeout)
54+
defer cancel()
55+
}
56+
if err := loadGraphCredentials(ctx, input, credentialsURL, credentialsFile, proxyAPIURL, flags.apiUrl); err != nil {
57+
return err
58+
}
5659

5760
// It doesn't make sense to suppress the graph output when running the graph command,
5861
// so forcing the experiment to true.
@@ -70,11 +73,20 @@ func NewGraphCommand() *cobra.Command {
7073
writer = os.Stdout
7174
}
7275

73-
if err := infra.Run(infra.RunParams{
76+
timeout := flags.timeout
77+
if timeout > 0 {
78+
timeout -= time.Since(started)
79+
if timeout <= 0 {
80+
return context.DeadlineExceeded
81+
}
82+
}
83+
if err := executeGraphJob(infra.RunParams{
7484
CacheDir: flags.cache,
7585
CollectorConfigPath: flags.collectorConfigPath,
7686
CollectorImage: collectorImage,
7787
Creds: input.Credentials,
88+
CredentialsResolved: credentialsURL != "",
89+
OmitCredentials: credentialsURL != "" || credentialsFile != "",
7890
Debug: flags.debugging,
7991
Flamegraph: flags.flamegraph,
8092
Expected: nil, // graph subcommand doesn't use expectations
@@ -86,24 +98,26 @@ func NewGraphCommand() *cobra.Command {
8698
ProxyCertPath: flags.proxyCertPath,
8799
ProxyImage: proxyImage,
88100
PullImages: flags.pullImages,
89-
Timeout: flags.timeout,
101+
Timeout: timeout,
90102
UpdaterImage: updaterImage,
91103
Volumes: flags.volumes,
92104
Writer: writer,
93105
ApiUrl: flags.apiUrl,
106+
ProxyApiUrl: proxyAPIURL,
94107
UpdaterEnvironmentVariables: flags.updaterEnvironmentVariables,
95108
}); err != nil {
96-
if errors.Is(err, context.DeadlineExceeded) {
97-
log.Fatalf("update timed out after %s", flags.timeout)
98-
}
99-
log.Fatalf("updater failure: %v", err)
109+
return fmt.Errorf("graph job failed: %w", err)
100110
}
101111

102112
return nil
103113
},
104114
}
105115

106116
cmd.Flags().StringVarP(&flags.file, "file", "f", "", "path to input file")
117+
cmd.Flags().StringVar(&credentialsURL, "credentials-url", "", "HTTPS job credentials endpoint (uses DEPENDABOT_CREDENTIALS_TOKEN)")
118+
cmd.Flags().StringVar(&credentialsFile, "credentials-file", "", "YAML file containing credentials for the graph proxy")
119+
cmd.Flags().StringVar(&proxyAPIURL, "proxy-api-url", "", "Dependabot backend for proxy authorization, separate from graph result callbacks (uses JOB_TOKEN)")
120+
cmd.MarkFlagsMutuallyExclusive("credentials-url", "credentials-file")
107121

108122
cmd.Flags().StringVarP(&flags.provider, "provider", "p", "github", "provider of the repository")
109123
cmd.Flags().StringVarP(&flags.branch, "branch", "b", "", "target branch to update")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"os"
8+
9+
"github.com/dependabot/cli/internal/model"
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
func loadGraphCredentials(ctx context.Context, input *model.Input, endpoint, file, proxyAPIURL, callbackURL string) error {
14+
if proxyAPIURL != "" {
15+
u, err := url.Parse(proxyAPIURL)
16+
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
17+
return fmt.Errorf("proxy-api-url must be an HTTPS URL without credentials, query, or fragment")
18+
}
19+
if os.Getenv("JOB_TOKEN") == "" {
20+
return fmt.Errorf("JOB_TOKEN is required with proxy-api-url")
21+
}
22+
}
23+
if endpoint == "" && file == "" {
24+
return nil
25+
}
26+
if endpoint != "" && file != "" {
27+
return fmt.Errorf("credentials-url and credentials-file are mutually exclusive")
28+
}
29+
if endpoint != "" {
30+
if input.Job.Command != "" && input.Job.Command != model.UpdateGraphCommand {
31+
return fmt.Errorf("credentials-url can only be used with a graph job")
32+
}
33+
if callbackURL != "" {
34+
return fmt.Errorf("credentials-url requires local graph callback capture; omit api-url")
35+
}
36+
creds, err := fetchGraphCredentials(ctx, endpoint, os.Getenv("DEPENDABOT_CREDENTIALS_TOKEN"))
37+
if err != nil {
38+
return fmt.Errorf("fetch graph credentials: %w", err)
39+
}
40+
input.Credentials = creds
41+
} else {
42+
data, err := os.ReadFile(file)
43+
if err != nil {
44+
return fmt.Errorf("read graph credentials file: %w", err)
45+
}
46+
var supplied struct {
47+
Credentials *[]model.Credential `yaml:"credentials"`
48+
}
49+
if err := yaml.Unmarshal(data, &supplied); err != nil || supplied.Credentials == nil {
50+
return fmt.Errorf("credentials file must contain a YAML credentials array")
51+
}
52+
input.Credentials = *supplied.Credentials
53+
}
54+
input.Job.CredentialsMetadata = nil
55+
for _, cred := range input.Credentials {
56+
typ, ok := cred["type"].(string)
57+
if !ok || typ == "" {
58+
return fmt.Errorf("each graph credential must have a type")
59+
}
60+
if typ == "jit_access" && proxyAPIURL == "" {
61+
return fmt.Errorf("jit_access credentials require proxy-api-url and JOB_TOKEN")
62+
}
63+
entry := make(model.Credential)
64+
for key, value := range cred {
65+
switch key {
66+
case "username", "password", "token", "key", "auth-key", "private-key", "client-secret", "secret":
67+
continue
68+
default:
69+
entry[key] = value
70+
}
71+
}
72+
input.Job.CredentialsMetadata = append(input.Job.CredentialsMetadata, entry)
73+
}
74+
return nil
75+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/dependabot/cli/internal/infra"
11+
"github.com/dependabot/cli/internal/model"
12+
)
13+
14+
func TestGraphHostedCredentialsKeepCallbacksLocal(t *testing.T) {
15+
t.Setenv("DEPENDABOT_CREDENTIALS_TOKEN", "opaque-credentials-token")
16+
t.Setenv("JOB_TOKEN", "opaque-job-token")
17+
t.Setenv("LOCAL_GITHUB_ACCESS_TOKEN", "must-not-be-used")
18+
previousRun, previousFetch := executeGraphJob, fetchGraphCredentials
19+
t.Cleanup(func() { executeGraphJob, fetchGraphCredentials = previousRun, previousFetch })
20+
fetchGraphCredentials = func(_ context.Context, endpoint, token string) ([]model.Credential, error) {
21+
if endpoint != "https://api.example/update_jobs/1/credentials" || token != "opaque-credentials-token" {
22+
t.Error("incorrect credential request")
23+
}
24+
return []model.Credential{{"type": "npm_registry", "registry": "registry.example", "token": "$literal-token"}}, nil
25+
}
26+
var called bool
27+
executeGraphJob = func(p infra.RunParams) error {
28+
called = true
29+
if p.ApiUrl != "" || p.ProxyApiUrl != "https://api.example" || !p.CredentialsResolved {
30+
t.Error("credential lookup must not redirect graph callbacks or expand resolved secrets")
31+
}
32+
if len(p.Creds) != 1 || p.Creds[0]["token"] != "$literal-token" {
33+
t.Error("hosted credentials must not include ambient local credentials")
34+
}
35+
if p.Job.Command != model.UpdateGraphCommand || p.Job.Experiments["enable_dependency_submission_poc"] != true {
36+
t.Error("must execute graph with dependency submission enabled")
37+
}
38+
if len(p.Job.CredentialsMetadata) != 1 || p.Job.CredentialsMetadata[0]["token"] != nil {
39+
t.Error("updater metadata must not contain secrets")
40+
}
41+
return nil
42+
}
43+
command := NewGraphCommand()
44+
command.SetArgs([]string{"npm_and_yarn", "owner/repo", "--credentials-url", "https://api.example/update_jobs/1/credentials", "--proxy-api-url", "https://api.example"})
45+
if err := command.Execute(); err != nil {
46+
t.Fatal(err)
47+
}
48+
if !called {
49+
t.Fatal("graph runner was not called")
50+
}
51+
}
52+
53+
func TestGraphCredentialFailureDoesNotRun(t *testing.T) {
54+
t.Setenv("DEPENDABOT_CREDENTIALS_TOKEN", "opaque-token")
55+
previousRun, previousFetch := executeGraphJob, fetchGraphCredentials
56+
t.Cleanup(func() { executeGraphJob, fetchGraphCredentials = previousRun, previousFetch })
57+
expected := errors.New("credentials denied")
58+
fetchGraphCredentials = func(context.Context, string, string) ([]model.Credential, error) { return nil, expected }
59+
executeGraphJob = func(infra.RunParams) error {
60+
t.Fatal("must not run without credentials")
61+
return nil
62+
}
63+
64+
command := NewGraphCommand()
65+
command.SetArgs([]string{"npm_and_yarn", "owner/repo", "--credentials-url", "https://api.example/credentials"})
66+
if err := command.Execute(); !errors.Is(err, expected) {
67+
t.Fatalf("expected credential error, got %v", err)
68+
}
69+
}
70+
71+
func TestGraphHostedCredentialsRejectOtherCommands(t *testing.T) {
72+
previousFetch := fetchGraphCredentials
73+
t.Cleanup(func() { fetchGraphCredentials = previousFetch })
74+
fetchGraphCredentials = func(context.Context, string, string) ([]model.Credential, error) {
75+
return []model.Credential{}, nil
76+
}
77+
input := &model.Input{Job: model.Job{Command: model.UpdateFilesCommand}}
78+
if err := loadGraphCredentials(context.Background(), input, "https://api.example/credentials", "", "", ""); err == nil {
79+
t.Fatal("hosted graph credentials must not execute an update job")
80+
}
81+
}
82+
83+
func TestGraphLocalCredentialFile(t *testing.T) {
84+
t.Setenv("LOCAL_GITHUB_ACCESS_TOKEN", "")
85+
previousRun := executeGraphJob
86+
t.Cleanup(func() { executeGraphJob = previousRun })
87+
file := filepath.Join(t.TempDir(), "credentials.yml")
88+
if err := os.WriteFile(file, []byte("credentials:\n- type: npm_registry\n registry: registry.example\n token: $NPM_TOKEN\n"), 0600); err != nil {
89+
t.Fatal(err)
90+
}
91+
92+
executeGraphJob = func(p infra.RunParams) error {
93+
if p.CredentialsResolved || len(p.Creds) != 1 || p.Creds[0]["token"] != "$NPM_TOKEN" {
94+
t.Error("local credential references must retain normal environment expansion")
95+
}
96+
if !p.OmitCredentials {
97+
t.Error("explicit credentials must be omitted from the captured scenario")
98+
}
99+
return nil
100+
}
101+
command := NewGraphCommand()
102+
command.SetArgs([]string{"npm_and_yarn", "owner/repo", "--credentials-file", file})
103+
if err := command.Execute(); err != nil {
104+
t.Fatal(err)
105+
}
106+
}

0 commit comments

Comments
 (0)