diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5afb5028 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.env* +dependabot +dependabot-action diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46ccd982..10f65145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,17 @@ jobs: echo "gofmt failed, please run gofmt -w ." exit 1 fi + + action: + name: Docker Action integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - run: go test ./cmd/dependabot-action -run '^TestDockerAction$' -count=1 -timeout=10m + env: + DEPENDABOT_ACTION_INTEGRATION: '1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c159b30..d2d52497 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,35 @@ permissions: packages: write jobs: + action-image: + name: Publish Docker Action + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Build and push Action image + id: action_image + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + docker buildx create --name action-builder --use + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag "ghcr.io/dependabot/cli-action:$RELEASE_TAG" \ + --tag ghcr.io/dependabot/cli-action:v1 \ + --metadata-file "$RUNNER_TEMP/action-image.json" \ + --push . + echo "digest=$(jq -r '."containerimage.digest"' "$RUNNER_TEMP/action-image.json")" >> "$GITHUB_OUTPUT" + + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ghcr.io/dependabot/cli-action + subject-digest: ${{ steps.action_image.outputs.digest }} + push-to-registry: true + releases-matrix: name: Release Go Binary runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..935fb717 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /dependabot-action ./cmd/dependabot-action + +FROM scratch +LABEL org.opencontainers.image.source="https://github.com/dependabot/cli" +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /dependabot-action /dependabot-action +ENTRYPOINT ["/dependabot-action"] diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..8b028032 --- /dev/null +++ b/action.yml @@ -0,0 +1,37 @@ +name: Dependabot job +description: Run an API-backed Dependabot job using the CLI's container runner +author: Dependabot +inputs: + api-url: + description: Dependabot API base URL, including any path prefix + required: true + job-id: + description: Dependabot update job ID + required: true + job-token: + description: Job token (alternatively set GITHUB_DEPENDABOT_JOB_TOKEN) + required: false + credentials-token: + description: Credentials token (alternatively set GITHUB_DEPENDABOT_CRED_TOKEN) + required: false + updater-image: + description: Updater image override; otherwise selected from the job's package manager + required: false + proxy-image: + description: Proxy image override + required: false + pull-images: + description: Pull images before running; false uses images already on the runner + default: 'true' + timeout: + description: Maximum execution duration, excluding API bootstrap (Go duration) + default: 60m + volumes: + description: Bind mounts, one absolute Action-container source:destination[:ro] per line + required: false + updater-env: + description: Additional updater environment variables, one NAME=value per line + required: false +runs: + using: docker + image: docker://ghcr.io/dependabot/cli-action:v1 diff --git a/cmd/dependabot-action/api.go b/cmd/dependabot-action/api.go new file mode 100644 index 00000000..fa8923b5 --- /dev/null +++ b/cmd/dependabot-action/api.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/dependabot/cli/internal/model" +) + +type jobClient struct { + config + http *http.Client + retryDelay time.Duration +} + +func newJobClient(cfg config) *jobClient { + return &jobClient{ + config: cfg, + http: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + retryDelay: time.Second, + } +} + +func (c *jobClient) details(ctx context.Context) (*model.Job, error) { + body, err := c.request(ctx, http.MethodGet, "details", c.jobToken, nil) + if err != nil { + return nil, err + } + var response struct { + Data struct { + Attributes *model.Job `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("invalid job details JSON") + } + job := response.Data.Attributes + if job == nil || job.PackageManager == "" || job.Source.Repo == "" { + return nil, fmt.Errorf("job details must include package-manager and source.repo") + } + return job, nil +} + +func (c *jobClient) credentials(ctx context.Context) ([]model.Credential, error) { + body, err := c.request(ctx, http.MethodGet, "credentials", c.credentialsToken, nil) + if err != nil { + return nil, err + } + var response struct { + Data struct { + Attributes struct { + Credentials *[]model.Credential `json:"credentials"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("invalid credentials JSON") + } + if response.Data.Attributes.Credentials == nil { + return nil, fmt.Errorf("credentials response must include a credentials array") + } + creds := *response.Data.Attributes.Credentials + for _, cred := range creds { + if typ, ok := cred["type"].(string); !ok || typ == "" { + return nil, fmt.Errorf("each credential must include a type") + } + } + return creds, nil +} + +func (c *jobClient) reportFailure(ctx context.Context, message string) error { + body, err := json.Marshal(map[string]any{"data": map[string]any{ + "error-type": "actions_workflow_updater", + "error-details": map[string]string{"action-error": message}, + }}) + if err != nil { + return err + } + if _, err := c.request(ctx, http.MethodPost, "record_update_job_error", c.jobToken, body); err != nil { + return err + } + _, err = c.request(ctx, http.MethodPatch, "mark_as_processed", c.jobToken, []byte(`{"data":{"base-commit-sha":"unknown"}}`)) + return err +} + +func (c *jobClient) request(ctx context.Context, method, endpoint, token string, body []byte) ([]byte, error) { + url := strings.TrimRight(c.apiURL, "/") + "/update_jobs/" + c.jobID + "/" + endpoint + for attempt := 0; attempt < 4; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("%s: cannot construct request", endpoint) + } + req.Header.Set("Authorization", token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "dependabot-cli-action") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := c.http.Do(req) + retry := false + var failure error + if err != nil { + failure = fmt.Errorf("%s: request failed: %w", endpoint, err) + retry = method == http.MethodGet + } else { + const maxResponseSize = 16 << 20 + data, readErr := io.ReadAll(io.LimitReader(res.Body, maxResponseSize+1)) + res.Body.Close() + if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusNoContent { + if readErr != nil || len(data) > maxResponseSize { + return nil, fmt.Errorf("%s: failed to read complete response", endpoint) + } + return data, nil + } + failure = fmt.Errorf("%s: HTTP %d", endpoint, res.StatusCode) + // Retrying callbacks could record the same failure twice. + retry = method == http.MethodGet && (res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500) + } + if !retry || attempt == 3 { + return nil, failure + } + timer := time.NewTimer(c.retryDelay * time.Duration(1< 0 { + volumes, err := hostVolumes(ctx, params.Volumes) + if err != nil { + return err + } + params.Volumes = volumes + } + return infra.RunContext(ctx, params) +} + +type masker struct { + output io.Writer + secrets []string +} + +func newMasker(output io.Writer) *masker { + return &masker{output: output} +} + +func (m *masker) add(secret string) error { + if secret == "" { + return nil + } + for _, existing := range m.secrets { + if existing == secret { + return nil + } + } + m.secrets = append(m.secrets, secret) + if _, err := fmt.Fprintln(m.output, "::add-mask::"+escapeCommand(secret)); err != nil { + return fmt.Errorf("register credential mask: %w", err) + } + return nil +} + +func (m *masker) credentials(creds []model.Credential) error { + for _, cred := range creds { + for key, value := range cred { + switch key { + case "password", "token", "key", "auth-key", "private-key", "client-secret", "secret", "username": + if secret, ok := value.(string); ok { + if err := m.add(secret); err != nil { + return err + } + } + } + } + } + return nil +} + +func (m *masker) redact(message string) string { + sort.SliceStable(m.secrets, func(i, j int) bool { return len(m.secrets[i]) > len(m.secrets[j]) }) + for _, secret := range m.secrets { + message = strings.ReplaceAll(message, secret, "***") + } + return message +} + +func escapeCommand(value string) string { + return strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A").Replace(value) +} diff --git a/cmd/dependabot-action/main_test.go b/cmd/dependabot-action/main_test.go new file mode 100644 index 00000000..1af3fdaa --- /dev/null +++ b/cmd/dependabot-action/main_test.go @@ -0,0 +1,227 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/dependabot/cli/internal/infra" + "github.com/dependabot/cli/internal/model" +) + +const testJob = `{"data":{"attributes":{"command":"update","package-manager":"npm_and_yarn","source":{"provider":"github","repo":"org/repo","hostname":"github.com","api-endpoint":"https://api.github.com","commit":"0123456789012345678901234567890123456789"},"credentials-metadata":[{"type":"git_source","host":"github.com"}]}}}` +const testCredentials = `{"data":{"attributes":{"credentials":[{"type":"git_source","host":"github.com","password":"target$TOKEN","accessible-repos":["org/repo"]},{"type":"git_source","host":"github.com","password":"dependency-secret","accessible-repos":["org/private-library"]},{"type":"npm_registry","token":"registry-secret"},{"type":"jit_access","endpoint":"/update_jobs/42/create_jit_access"}]}}}` + +func TestRunHostedJob(t *testing.T) { + t.Setenv("JOB_TOKEN", "") + t.Setenv("DEPENDABOT_JOB_ID", "") + var paths []string + var output bytes.Buffer + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/prefix/update_jobs/42/details": + if r.Header.Get("Authorization") != "job-secret" { + t.Error("details did not use job token") + } + if !strings.Contains(output.String(), "::add-mask::job-secret") { + t.Error("bootstrap token was not masked before fetching") + } + fmt.Fprint(w, testJob) + case "/prefix/update_jobs/42/credentials": + if r.Header.Get("Authorization") != "cred-secret" { + t.Error("credentials did not use credential token") + } + fmt.Fprint(w, testCredentials) + default: + t.Errorf("unexpected callback: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + cfg := config{ + apiURL: server.URL + "/prefix", jobID: "42", jobToken: "job-secret", credentialsToken: "cred-secret", + updaterImage: "custom:job", proxyImage: "custom:proxy", pullImages: false, + timeout: 3 * time.Hour, volumes: []string{"/github/workspace/cache:/cache"}, + updaterEnv: []string{"CUSTOM_CACHE_PATH=/cache"}, + } + api := newJobClient(cfg) + api.http = server.Client() + called := false + err := run(context.Background(), cfg, api, &output, func(ctx context.Context, params infra.RunParams) error { + called = true + if len(paths) != 2 { + t.Fatal("runner invoked before fetching both responses") + } + if params.Job.Command != "update" || params.Job.Source.Hostname == nil || *params.Job.Source.Hostname != "github.com" || params.Job.Source.Commit != "0123456789012345678901234567890123456789" { + t.Fatalf("job was changed: %+v", params.Job) + } + if len(params.Creds) != 4 || params.Creds[0]["password"] != "target$TOKEN" || !params.CredentialsResolved { + t.Fatalf("credentials not passed intact: %+v", params.Creds) + } + for _, secret := range []string{"target$TOKEN", "dependency-secret", "registry-secret", "cred-secret"} { + if !strings.Contains(output.String(), "::add-mask::"+secret+"\n") { + t.Errorf("%q not masked before execution", secret) + } + } + if params.ApiUrl != cfg.apiURL || params.UpdaterImage != cfg.updaterImage || params.ProxyImage != cfg.proxyImage || + params.PullImages || params.Timeout != cfg.timeout || params.StorageImage != infra.StorageImageName || + !reflect.DeepEqual(params.Volumes, cfg.volumes) || !reflect.DeepEqual(params.UpdaterEnvironmentVariables, cfg.updaterEnv) { + t.Fatalf("incorrect runner options: %+v", params) + } + if params.Output != "" || params.Writer != nil { + t.Error("hosted jobs must not emit smoke test files or fake API output") + } + if got := os.Getenv("JOB_TOKEN"); got != cfg.jobToken { + t.Errorf("proxy token = %q", got) + } + if got := os.Getenv("DEPENDABOT_JOB_ID"); got != cfg.jobID { + t.Errorf("job ID = %q", got) + } + return nil + }) + if err != nil || !called { + t.Fatalf("run() = %v, called = %v", err, called) + } +} + +func TestHostedJobFailures(t *testing.T) { + for _, failure := range []string{"details", "credentials", "runner", "report", "complete", "cancel"} { + t.Run(failure, func(t *testing.T) { + t.Setenv("JOB_TOKEN", "") + t.Setenv("DEPENDABOT_JOB_ID", "") + var paths []string + var report string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + endpoint := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + paths = append(paths, endpoint) + switch endpoint { + case "details": + if failure == "details" { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "do-not-log-response") + } else { + fmt.Fprint(w, testJob) + } + case "credentials": + if failure == "credentials" { + w.WriteHeader(http.StatusUnauthorized) + } else { + fmt.Fprint(w, testCredentials) + } + case "record_update_job_error": + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "job-secret" { + t.Error("incorrect error callback") + } + body, _ := io.ReadAll(r.Body) + report = string(body) + if failure == "report" { + w.WriteHeader(http.StatusForbidden) + } else { + w.WriteHeader(http.StatusNoContent) + } + case "mark_as_processed": + if r.Method != http.MethodPatch { + t.Error("incorrect completion method") + } + var body map[string]map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["data"]["base-commit-sha"] != "unknown" { + t.Errorf("incorrect completion body: %v, %v", body, err) + } + if failure == "complete" { + w.WriteHeader(http.StatusForbidden) + } else { + w.WriteHeader(http.StatusNoContent) + } + default: + t.Errorf("unexpected endpoint %q", endpoint) + } + })) + defer server.Close() + cfg := config{apiURL: server.URL, jobID: "42", jobToken: "job-secret", credentialsToken: "cred-secret"} + api := newJobClient(cfg) + api.http = server.Client() + var output bytes.Buffer + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + err := run(ctx, cfg, api, &output, func(context.Context, infra.RunParams) error { + if failure == "cancel" { + cancel() + return context.Canceled + } + return errors.New("updater failed with registry-secret") + }) + if err == nil { + t.Fatal("failure was swallowed") + } + if strings.Contains(err.Error(), "do-not-log-response") || strings.Contains(err.Error(), "registry-secret") || strings.Contains(report, "registry-secret") { + t.Fatalf("secret or response leaked: %v, %s", err, report) + } + switch failure { + case "details": + if !reflect.DeepEqual(paths, []string{"details"}) { + t.Fatalf("reported failure before job entered processing: %v", paths) + } + case "cancel": + if len(paths) != 2 { + t.Fatalf("canceled job made callbacks: %v", paths) + } + case "report": + if !strings.Contains(err.Error(), "report") || paths[len(paths)-1] != "record_update_job_error" { + t.Fatalf("report failure ignored: %v, %v", err, paths) + } + default: + if paths[len(paths)-1] != "mark_as_processed" || !strings.Contains(report, "actions_workflow_updater") { + t.Fatalf("missing failure lifecycle: %v, %s", paths, report) + } + if failure == "complete" && !strings.Contains(err.Error(), "mark_as_processed") { + t.Fatalf("completion failure ignored: %v", err) + } + } + }) + } +} + +func TestMaskEscapesWorkflowCommands(t *testing.T) { + var output bytes.Buffer + mask := newMasker(&output) + mask.add("abc%\r\n::error::bad") + mask.add("abc%\r\n::error::bad") + if got, want := output.String(), "::add-mask::abc%25%0D%0A::error::bad\n"; got != want { + t.Fatalf("mask output = %q, want %q", got, want) + } + + if got := mask.redact("failure abc%\r\n::error::bad"); got != "failure ***" { + t.Fatalf("redaction = %q", got) + } +} + +func TestMaskCredentialFields(t *testing.T) { + var output bytes.Buffer + mask := newMasker(&output) + creds := []model.Credential{{ + "password": "password-value", "token": "token-value", "key": "key-value", + "auth-key": "auth-key-value", "username": "username-value", + "client-secret": "client-secret-value", "private-key": "private-key-value", + "secret": "secret-value", + }} + if err := mask.credentials(creds); err != nil { + t.Fatal(err) + } + for _, value := range creds[0] { + secret := value.(string) + if !strings.Contains(output.String(), "::add-mask::"+secret+"\n") || mask.redact(secret) != "***" { + t.Errorf("credential was not masked/redacted: %s", secret) + } + } +} diff --git a/cmd/dependabot-action/mounts.go b/cmd/dependabot-action/mounts.go new file mode 100644 index 00000000..182b3a70 --- /dev/null +++ b/cmd/dependabot-action/mounts.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" +) + +func hostVolumes(ctx context.Context, volumes []string) ([]string, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("create Docker client for volume translation: %w", err) + } + defer cli.Close() + id, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("identify Action container: %w", err) + } + self, err := cli.ContainerInspect(ctx, id) + if err != nil { + return nil, fmt.Errorf("inspect Action container mounts: %w", err) + } + return translateVolumes(volumes, self.Mounts) +} + +func translateVolumes(volumes []string, mounts []container.MountPoint) ([]string, error) { + translated := make([]string, 0, len(volumes)) + for _, volume := range volumes { + parts := strings.Split(volume, ":") + if len(parts) < 2 || len(parts) > 3 || !filepath.IsAbs(parts[0]) || !filepath.IsAbs(parts[1]) || + (len(parts) == 3 && parts[2] != "ro") { + return nil, fmt.Errorf("volumes must be absolute source:destination[:ro] paths") + } + source := filepath.Clean(parts[0]) + var best *container.MountPoint + var relative string + for i := range mounts { + m := &mounts[i] + rel, err := filepath.Rel(m.Destination, source) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + if best == nil || len(m.Destination) > len(best.Destination) { + best, relative = m, rel + } + } + if best == nil || best.Type != mount.TypeBind { + return nil, fmt.Errorf("volume source %q is not inside an Action bind mount", source) + } + if !best.RW && len(parts) != 3 { + return nil, fmt.Errorf("volume source %q is read-only in the Action container", source) + } + // Sibling containers resolve bind sources on the daemon host, not in this container. + parts[0] = filepath.Join(best.Source, relative) + translated = append(translated, strings.Join(parts, ":")) + } + return translated, nil +} diff --git a/cmd/dependabot-action/testdata/Dockerfile b/cmd/dependabot-action/testdata/Dockerfile new file mode 100644 index 00000000..9d0a4492 --- /dev/null +++ b/cmd/dependabot-action/testdata/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.23 AS base +COPY update-ca-certificates /usr/local/bin/update-ca-certificates +RUN chmod 755 /usr/local/bin/update-ca-certificates + +FROM base AS proxy +RUN apk add --no-cache busybox-extras +COPY proxy /dependabot-proxy +RUN chmod 755 /dependabot-proxy && mkdir -p /srv + +FROM base AS updater +RUN adduser -D dependabot && mkdir -p /home/dependabot/dependabot-updater +COPY run /home/dependabot/bin/run +RUN chmod 755 /home/dependabot/bin/run +USER dependabot +WORKDIR /home/dependabot diff --git a/cmd/dependabot-action/testdata/proxy b/cmd/dependabot-action/testdata/proxy new file mode 100644 index 00000000..985520f5 --- /dev/null +++ b/cmd/dependabot-action/testdata/proxy @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +grep -Fq '"password":"target$TOKEN"' /config.json +grep -Fq '"token":"registry-secret"' /config.json +grep -Fq '"accessible-repos":["org/private-library"]' /config.json +[ "$JOB_TOKEN" = "job-secret" ] +[ "$JOB_ID" = "42" ] +printf '%s\n' proxy-credentials-ok > /srv/index.html +exec httpd -f -p 1080 -h /srv diff --git a/cmd/dependabot-action/testdata/run b/cmd/dependabot-action/testdata/run new file mode 100644 index 00000000..e55c767a --- /dev/null +++ b/cmd/dependabot-action/testdata/run @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu +[ "$DEPENDABOT_JOB_ID" = "42" ] +[ -z "$DEPENDABOT_JOB_TOKEN" ] +[ -z "${GITHUB_DEPENDABOT_JOB_TOKEN:-}" ] +[ -z "${GITHUB_DEPENDABOT_CRED_TOKEN:-}" ] +! grep -Eq 'target|registry-secret|dependency-secret' "$DEPENDABOT_JOB_PATH" +grep -Fq '"commit":"0123456789012345678901234567890123456789"' "$DEPENDABOT_JOB_PATH" +printf 'job=%s\n' "$DEPENDABOT_JOB_ID" > "$CACHE_PATH/result" +for attempt in $(seq 1 20); do + if response=$(http_proxy= HTTP_PROXY= wget -qO- "$HTTP_PROXY"); then + printf '%s\n' "$response" + [ "$FAIL" = false ] + exit + fi + sleep 0.1 +done +echo "proxy did not become ready" >&2 +exit 1 diff --git a/cmd/dependabot-action/testdata/update-ca-certificates b/cmd/dependabot-action/testdata/update-ca-certificates new file mode 100644 index 00000000..039e4d00 --- /dev/null +++ b/cmd/dependabot-action/testdata/update-ca-certificates @@ -0,0 +1,2 @@ +#!/bin/sh +exit 0 diff --git a/internal/infra/run.go b/internal/infra/run.go index e9c6f49a..411c74e0 100644 --- a/internal/infra/run.go +++ b/internal/infra/run.go @@ -59,6 +59,8 @@ type RunParams struct { LocalDir string // credentials passed to the proxy Creds []model.Credential + // CredentialsResolved disables environment expansion and credential capture in smoke test output. + CredentialsResolved bool // local directory used for caching CacheDir string // write output to a file @@ -117,23 +119,25 @@ func (p *RunParams) Validate() error { } func Run(params RunParams) error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return RunContext(ctx, params) +} + +// RunContext runs a job until completion, cancellation, or its configured timeout. +func RunContext(ctx context.Context, params RunParams) error { if err := params.Validate(); err != nil { return 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()) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, params.Timeout) + defer cancel() + } + if err := ctx.Err(); err != nil { + return err } - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-signals - cancel() - }() api := server.NewAPI(params.Expected, params.Writer) defer api.Stop() @@ -334,6 +338,10 @@ func setImageNames(params *RunParams) error { } func expandEnvironmentVariables(api *server.API, params *RunParams) { + if params.CredentialsResolved { + return + } + if api != nil { api.Actual.Input.Credentials = params.Creds diff --git a/internal/infra/run_test.go b/internal/infra/run_test.go index 061e18f4..ce79fb97 100644 --- a/internal/infra/run_test.go +++ b/internal/infra/run_test.go @@ -241,6 +241,24 @@ func Test_checkCredAccess(t *testing.T) { } func Test_expandEnvironmentVariables(t *testing.T) { + t.Run("keeps resolved API credentials literal and out of smoke test output", func(t *testing.T) { + t.Setenv("SECRET", "expanded") + api := &server.API{} + params := &RunParams{ + CredentialsResolved: true, + Creds: []model.Credential{{ + "type": "npm_registry", "token": "literal$SECRET", + }}, + } + expandEnvironmentVariables(api, params) + if got := params.Creds[0]["token"]; got != "literal$SECRET" { + t.Fatalf("resolved credential changed: %v", got) + } + if len(api.Actual.Input.Credentials) != 0 { + t.Fatal("resolved credentials must not be included in smoke test output") + } + }) + t.Run("injects environment variables", func(t *testing.T) { os.Setenv("ENV1", "value1") os.Setenv("ENV2", "value2") @@ -271,6 +289,15 @@ func Test_expandEnvironmentVariables(t *testing.T) { }) } +func TestRunContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunContext(ctx, RunParams{Job: &model.Job{PackageManager: "npm_and_yarn"}}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("RunContext() = %v, want context.Canceled", err) + } +} + func Test_generateIgnoreConditions(t *testing.T) { const ( outputFileName = "test_output"