Repository navigation
Add egress control through allowlist to prevent source code exfiltration #230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
v-abhishekbhaskar
merged 8 commits into
main
from
abhishekbhaskar/add-egress-allowlist-log-only
Sep 10, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a1b77e8
add egress allowlist functionality
v-abhishekbhaskar 4b190ef
delete egress control implementation plan doc
v-abhishekbhaskar a9785ea
add missing ecosystems and update subdomain suffix matcher
v-abhishekbhaskar 2870b43
add non-allowlisted domain metrics and add experiments for logging an…
v-abhishekbhaskar 394cfda
add remaining ecosystems to allowlist
v-abhishekbhaskar cc59549
remve indexing by package_manager and move allowlist to yaml file
v-abhishekbhaskar ed312a3
remove egress_not_allowlisted_count metric and add dynamic hosts infe…
v-abhishekbhaskar f6b9100
remove functionality for supporting backend-supplied domains
v-abhishekbhaskar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| /dependabot-proxy | ||
| /proxy | ||
| /config.json | ||
| /tmp | ||
| /tmp | ||
| /.vscode |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "github.com/elazarl/goproxy" | ||
|
|
||
| "github.com/dependabot/proxy/internal/config" | ||
| "github.com/dependabot/proxy/internal/helpers" | ||
| "github.com/dependabot/proxy/internal/logging" | ||
| ) | ||
|
|
||
| // Experiment flags (job experiments) that toggle egress filtering. They are | ||
| // independent: observe logs non-allowlisted hosts, enforce drops them with a | ||
| // 403. Both default off (fail-open) when absent. | ||
| const ( | ||
| egressObserveExperiment = "proxy_egress_observe" | ||
| egressEnforceExperiment = "proxy_egress_enforce" | ||
| ) | ||
|
|
||
| // EgressAllowlistHandler filters outbound requests against a per-job allowlist | ||
| // of non-hostile domains. In observe mode it only logs non-allowlisted hosts; | ||
| // in enforce mode it drops them with a 403. When neither flag is set it allows | ||
| // all traffic (fail-open). | ||
| type EgressAllowlistHandler struct { | ||
| observe bool | ||
| enforce bool | ||
| allowed []string | ||
| } | ||
|
|
||
| // NewEgressAllowlistHandler builds the allowlist from the always-allowed GitHub | ||
| // infrastructure domains, the union of every ecosystem's default registry hosts, | ||
| // and the job's dynamic hosts (configured registries and OIDC token-exchange | ||
| // endpoints derived from cfg.Credentials). The observe/enforce toggles are | ||
| // driven by job experiments. | ||
| func NewEgressAllowlistHandler(cfg *config.Config, env config.ProxyEnvSettings) *EgressAllowlistHandler { | ||
| allowed := append([]string(nil), githubInfraDomains...) | ||
| allowed = append(allowed, allEcosystemDomains...) | ||
| allowed = append(allowed, dynamicHosts(cfg.Credentials)...) | ||
|
|
||
| return &EgressAllowlistHandler{ | ||
| observe: cfg.Experiments.Enabled(egressObserveExperiment), | ||
| enforce: cfg.Experiments.Enabled(egressEnforceExperiment), | ||
| allowed: allowed, | ||
| } | ||
| } | ||
|
|
||
| // HandleRequest logs and/or blocks requests whose host is not on the allowlist. | ||
| func (h *EgressAllowlistHandler) HandleRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) { | ||
| if !h.observe && !h.enforce { | ||
| return req, nil | ||
| } | ||
|
|
||
| host := helpers.GetHost(req) | ||
| if host == "" || h.isAllowed(host) { | ||
| return req, nil | ||
| } | ||
|
|
||
| if h.observe { | ||
| logging.RequestLogf(proxyCtx, "* egress not allowlisted %s", host) | ||
| } | ||
| if h.enforce { | ||
| return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusForbidden, "Forbidden") | ||
| } | ||
| return req, nil | ||
| } | ||
|
|
||
| func (h *EgressAllowlistHandler) isAllowed(host string) bool { | ||
| // Normalize an absolute DNS name (trailing dot) so exact matches treat | ||
| // "registry.npmjs.org." as equivalent to "registry.npmjs.org", consistent | ||
| // with HostMatchesDomain's boundary handling for the suffix form. | ||
| host = strings.TrimSuffix(host, ".") | ||
| for _, entry := range h.allowed { | ||
| // A leading dot means "this domain and any subdomain"; otherwise the | ||
| // entry must match the host exactly. | ||
| if domain, ok := strings.CutPrefix(entry, "."); ok { | ||
| if helpers.HostMatchesDomain(host, domain) { | ||
| return true | ||
| } | ||
| } else if helpers.AreHostnamesEqual(host, entry) { | ||
|
v-abhishekbhaskar marked this conversation as resolved.
|
||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| _ "embed" | ||
| "fmt" | ||
| "slices" | ||
|
|
||
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| // egressDefaultsYAML holds the allowlist defaults. It is the single source of | ||
| // truth for the GitHub infrastructure domains and per-ecosystem registry hosts; | ||
| // see the file itself for the matching convention and provenance notes. | ||
| // | ||
| //go:embed egress_allowlist_defaults.yaml | ||
| var egressDefaultsYAML []byte | ||
|
|
||
| // egressDefaults is the parsed representation of egress_allowlist_defaults.yaml. | ||
| type egressDefaults struct { | ||
| GithubInfraDomains []string `yaml:"github_infra_domains"` | ||
| EcosystemDefaultDomains map[string][]string `yaml:"ecosystem_default_domains"` | ||
| } | ||
|
|
||
| var ( | ||
| // githubInfraDomains are the GitHub/Dependabot infrastructure domains that | ||
| // are always allowed, regardless of ecosystem. | ||
| githubInfraDomains []string | ||
|
|
||
| // ecosystemDefaultDomains maps each Dependabot ecosystem to the public | ||
| // registry/CDN hosts it needs. Retained for provenance/documentation; the | ||
| // handler applies the union (allEcosystemDomains), not a per-key lookup. | ||
| ecosystemDefaultDomains map[string][]string | ||
|
|
||
| // allEcosystemDomains is the deduplicated union of every ecosystem's | ||
| // defaults, applied to all jobs. We intentionally do not partition by | ||
| // PACKAGE_MANAGER: launchers do not reliably set it, and multi-ecosystem | ||
| // jobs need several ecosystems at once. All entries are trusted public | ||
| // registries, so the loss of cross-ecosystem isolation is negligible versus | ||
| // the exfiltration protection (unknown hosts are still blocked). | ||
| allEcosystemDomains []string | ||
| ) | ||
|
|
||
| func init() { | ||
| var defaults egressDefaults | ||
| if err := yaml.Unmarshal(egressDefaultsYAML, &defaults); err != nil { | ||
| panic(fmt.Sprintf("parsing egress_allowlist_defaults.yaml: %v", err)) | ||
| } | ||
|
|
||
| githubInfraDomains = defaults.GithubInfraDomains | ||
| ecosystemDefaultDomains = defaults.EcosystemDefaultDomains | ||
|
|
||
| seen := make(map[string]struct{}) | ||
| for _, hosts := range ecosystemDefaultDomains { | ||
| for _, host := range hosts { | ||
| if _, ok := seen[host]; ok { | ||
| continue | ||
| } | ||
| seen[host] = struct{}{} | ||
| allEcosystemDomains = append(allEcosystemDomains, host) | ||
| } | ||
| } | ||
| slices.Sort(allEcosystemDomains) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| # Egress allowlist default domains. | ||
| # | ||
| # Matching convention (see EgressAllowlistHandler): a leading dot (".github.com") | ||
| # matches that domain and any subdomain, while an entry without a leading dot | ||
| # ("storage.googleapis.com") matches only that exact host. Prefer exact hosts. | ||
| # Use the leading-dot form only for domains whose subdomains are entirely | ||
| # provider-controlled and never user-creatable, so that user-controlled | ||
| # subdomains — e.g. object-storage buckets reachable as | ||
| # <bucket>.storage.googleapis.com — cannot be abused as an exfiltration channel. | ||
|
|
||
| # github_infra_domains are the GitHub and Dependabot infrastructure domains that | ||
| # are always allowed. Their subdomains are entirely GitHub-controlled (api., | ||
| # codeload., objects./raw.githubusercontent.com, api.<tenant>.ghe.com), so the | ||
| # leading-dot suffix form is safe here. | ||
| github_infra_domains: | ||
| - .github.com | ||
| - .githubusercontent.com | ||
| - .githubapp.com | ||
| - .ghe.com | ||
| - ghcr.io | ||
|
|
||
| # ecosystem_default_domains lists the public registry and CDN hosts each | ||
| # Dependabot ecosystem needs. The map is kept keyed by ecosystem for provenance | ||
| # and documentation, but the handler applies the UNION of all values to every | ||
| # job (it does not partition by PACKAGE_MANAGER). Entries are exact hosts unless | ||
| # a leading dot is present. Object-storage hosts (storage.googleapis.com) are | ||
| # intentionally exact so user-created buckets under them are not allowlisted. | ||
| # | ||
| # Every registered Dependabot ecosystem has an entry. An empty list means the | ||
| # ecosystem reaches only GitHub/Dependabot infrastructure or plain git (covered | ||
| # by github_infra_domains and the job's own git credentials), or its registries | ||
| # are configured per job: github_actions, submodules, swift, vcpkg, pre_commit | ||
| # (all git/GitHub-hosted) and helm (chart repositories supplied per job). Keys | ||
| # must match the ecosystem internal names registered in dependabot-core | ||
| # (Dependabot::FileFetchers.register). | ||
| ecosystem_default_domains: | ||
| npm_and_yarn: | ||
| - registry.npmjs.org | ||
| - registry.yarnpkg.com | ||
| - jsr.io | ||
| - registry.bower.io | ||
| bun: | ||
| - registry.npmjs.org | ||
| - registry.yarnpkg.com | ||
| - jsr.io | ||
| - registry.bower.io | ||
| pip: | ||
| - pypi.org | ||
| - pypi.python.org | ||
| - files.pythonhosted.org | ||
| - pythonhosted.org | ||
| uv: | ||
| - pypi.org | ||
| - pypi.python.org | ||
| - files.pythonhosted.org | ||
| - pythonhosted.org | ||
| bundler: | ||
| - rubygems.org | ||
| - index.rubygems.org | ||
| - gems.rubygems.org | ||
| maven: | ||
| - repo.maven.apache.org | ||
| - repo1.maven.org | ||
| - plugins.gradle.org | ||
| - dl.google.com | ||
| gradle: | ||
| - repo.maven.apache.org | ||
| - repo1.maven.org | ||
| - plugins.gradle.org | ||
| - dl.google.com | ||
| sbt: | ||
| - repo1.maven.org | ||
| - repo.maven.apache.org | ||
| - repo.scala-sbt.org | ||
| cargo: | ||
| - crates.io | ||
| - static.crates.io | ||
| - index.crates.io | ||
| composer: | ||
| - repo.packagist.org | ||
| - packagist.org | ||
| go_modules: | ||
| - proxy.golang.org | ||
| - sum.golang.org | ||
| - storage.googleapis.com | ||
| docker: &docker_registries | ||
| - registry-1.docker.io | ||
| - auth.docker.io | ||
| - index.docker.io | ||
| - registry.hub.docker.com | ||
| - production.cloudflare.docker.com | ||
| - ghcr.io | ||
| - mcr.microsoft.com | ||
| - quay.io | ||
| - public.ecr.aws | ||
| - .gcr.io | ||
| - .pkg.dev | ||
| docker_compose: *docker_registries | ||
| nuget: | ||
| - api.nuget.org | ||
| dotnet_sdk: | ||
| - api.nuget.org | ||
| hex: | ||
| - repo.hex.pm | ||
| - hex.pm | ||
| pub: | ||
| - pub.dev | ||
| - pub.dartlang.org | ||
| - storage.googleapis.com | ||
| terraform: | ||
| - registry.terraform.io | ||
| - releases.hashicorp.com | ||
| opentofu: | ||
| - registry.opentofu.org | ||
| - releases.hashicorp.com | ||
| elm: | ||
| - package.elm-lang.org | ||
| deno: | ||
| - jsr.io | ||
| - deno.land | ||
| - registry.npmjs.org | ||
| bazel: | ||
| - bcr.bazel.build | ||
| julia: | ||
| - pkg.julialang.org | ||
| rust_toolchain: | ||
| - static.rust-lang.org | ||
| conda: | ||
| - api.anaconda.org | ||
| - anaconda.org | ||
| - conda.anaconda.org | ||
| - repo.anaconda.com | ||
| nix: | ||
| - channels.nixos.org | ||
| devcontainers: *docker_registries | ||
| # helm pulls OCI charts from container registries, so it shares the docker set | ||
| # (chart-repo HTTP registries are still supplied per job via credentials). | ||
| helm: *docker_registries | ||
| # GitHub-hosted or per-job configured; covered by github_infra_domains / git. | ||
| # Note: additional per-job dependencies (e.g. pre_commit hooks that install | ||
| # from other ecosystems) are covered because the handler applies the union of | ||
| # ALL ecosystem hosts to every job, not just the job's own package manager. | ||
| github_actions: [] | ||
| submodules: [] | ||
| swift: [] | ||
| vcpkg: [] | ||
| pre_commit: [] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.