The Download Is Only Half the Attack: Containing Supply-Chain Compromise with Runner Egress Controls

A follow-up to "Terraform Providers — The Overlooked Supply Chain Attack Vector"
In our previous article, we looked at a trust boundary that is easy to miss: a Terraform provider is not passive configuration. It is an executable that runs alongside Terraform, with access to the same environment, credentials, files, and network. Simply running terraform init is enough for a malicious provider to execute a payload.
That makes reviewing modules, pinning provider versions, committing dependency lock files, and validating sources essential. It also raises a harder question:
What happens when prevention fails and an untrusted binary executes anyway?
This is not a Terraform-only question. The same problem exists with a compromised GitHub Action, a malicious package installation script, a poisoned container image, or a build tool downloaded at runtime. Any of them may execute on a CI runner that holds cloud credentials and can reach sensitive infrastructure.
Many organizations carefully control inbound access to those runners while leaving outbound access almost unrestricted. That is a dangerous imbalance. A payload may execute locally, but most supply-chain attacks need a second step to create lasting impact: contact command-and-control infrastructure, download another stage, or exfiltrate credentials and data.
Egress control gives defenders another chance to break that chain.
The CI Runner Is Part of the Security Boundary
A self-hosted GitHub Actions runner is a particularly valuable target. Depending on the workflow, it may have access to:
- short-lived cloud credentials;
- repository and package-registry tokens;
- Terraform state and plan files;
- deployment secrets;
- source code and build artifacts; and
- private services reachable only from inside the VPC.
The exact credentials may be short-lived, but short-lived does not mean harmless. A malicious process can use or transmit them while they are valid. Network placement can also be more valuable than credentials: code running on a trusted runner may be able to probe internal services that are unreachable from the internet.
The safest approach is to assume that any job can eventually execute something you did not intend. The objective then becomes limiting the destinations that code can reach and the data paths it can use.
A Practical AWS Architecture
For an EC2-based runner in AWS, a useful pattern is:

The architecture has four important properties:
- The runner has no public IP address and no unrestricted route it can successfully use for outbound connections.
- Its security group permits internet-bound traffic only to the proxy listener, normally TCP port 3128.
- Access to required AWS services uses narrowly selected VPC endpoints rather than the public internet.
- The proxy allows approved destinations, denies everything else, and exports its access logs.
The security group is the enforcement point that prevents a process from bypassing the proxy. Proxy environment variables alone are only configuration: malicious code can ignore them and open a direct socket. If the runner security group still allows 0.0.0.0/0 on ports 80 and 443, the proxy is optional from an attacker's perspective.
Layer One: Make the Proxy the Only Internet Path
The following is a simplified version of the runner-side control. Production code will need to account for how the proxy is exposed—by security group, subnet CIDR, or load balancer—but the invariant should remain the same: there is no general internet egress rule.
resource "aws_security_group" "runner" {
name = "github-runner"
vpc_id = var.vpc_id
}
# The internal NLB nodes are in these dedicated proxy subnets.
resource "aws_vpc_security_group_egress_rule" "proxy" {
for_each = toset(var.proxy_subnet_cidrs)
security_group_id = aws_security_group.runner.id
description = "HTTPS and HTTP through the approved proxy only"
ip_protocol = "tcp"
from_port = 3128
to_port = 3128
cidr_ipv4 = each.value
}
Do not add "temporary" 0.0.0.0/0 rules for ports 80 or 443 and then forget about them. Treat a broad egress rule as a failed control in policy checks, just as you would an internet-exposed administrative port.
Where possible, place runners in dedicated subnets. This makes routing, proxy policy, flow-log analysis, and incident isolation easier. It also prevents a broad allowlist created for a general application subnet from silently becoming the runner's policy.
One default-deny nuance is worth knowing before it surprises you during testing: security groups do not filter traffic to the Amazon-provided VPC DNS resolver. Even with the egress rules above, processes on the runner can still resolve names. That is convenient—the proxy path keeps working—but it also means DNS remains a live channel, which we return to in the limitations section below.
Layer Two: Allow Destinations, Not "the Web"
Squid can provide domain-based policy without decrypting TLS. For HTTPS, the client asks the proxy to create a CONNECT tunnel to a destination host. Squid can allow or deny that host while leaving the encrypted session intact.
A generalized policy might look like this:
http_port 3128
acl runner_clients src 10.20.10.0/24
acl runner_domains dstdomain "/etc/squid/runner-domains.txt"
# Never let the proxy become a bridge to internal networks
# or the EC2 instance metadata service.
acl to_internal dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
acl to_metadata dst 169.254.169.254/32 169.254.0.0/16
acl SSL_ports port 443
acl Safe_ports port 80 443
acl CONNECT method CONNECT
http_access deny to_internal
http_access deny to_metadata
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow runner_clients runner_domains
http_access deny all
cache deny all
access_log /var/log/squid/access.log squid
The two internal-destination denies are not optional hardening. Without them, a forward proxy reachable from the runner subnet can itself become a pivot: a process that cannot open a direct socket to an internal service may still ask the proxy to connect there on its behalf. The proxy should only ever broker connections outward, and it should never be able to fetch instance credentials from the metadata service on a client's behalf.
Two operational details matter for the src ACL. First, it is what lets different runner groups receive different policies, so keep runner groups in distinct, dedicated CIDRs. Second, it only works if the Network Load Balancer in front of Squid preserves the client IP address. Client IP preservation is the default for TCP targets, but if it is ever disabled, every runner will appear to Squid as an NLB node address and your per-group policy will silently stop matching. Verify it, and test it after load balancer changes.
The corresponding domain file should begin small and be owned as code:
github.com
api.github.com
.actions.githubusercontent.com
codeload.github.com
.blob.core.windows.net
objects.githubusercontent.com
release-assets.githubusercontent.com
registry.terraform.io
releases.hashicorp.com
Note the leading-dot entries: .actions.githubusercontent.com matches the domain and all of its subdomains, so individual hosts such as results-receiver.actions.githubusercontent.com do not need their own lines. Use the suffix form deliberately—every leading dot is a decision to trust an entire namespace.
This is only a starting point. GitHub publishes its runner destinations by function and warns that some use CNAME records whose targets can change. Workflows also add their own dependencies, such as container registries, operating-system repositories, and cloud APIs. Build the list from the workflows that actually run on a runner group, then compare it periodically with GitHub's current communication requirements.
Avoid broad suffixes such as .amazonaws.com, .githubusercontent.com, or an entire cloud-storage domain when a smaller set works. A shared service that is legitimate for your build may also be usable by an attacker. Every allowlisted destination is a residual exfiltration path.
The Terraform module in our AWS implementation turns this policy into a map of client CIDRs and domain lists. That lets different runner groups receive different policies rather than sharing one ever-growing global allowlist:
allow_lists = {
terraform_runners = {
cidrs = ["10.20.10.0/24"]
domains = [
"github.com",
"api.github.com",
".actions.githubusercontent.com",
"codeload.github.com",
"registry.terraform.io",
"releases.hashicorp.com",
]
allow_all = false
}
}
Keep allow_all = false as a deployment invariant. A proxy that records unrestricted traffic can be useful during discovery, but it is monitoring—not containment.
Layer Three: Configure Every Execution Context
GitHub supports http_proxy, https_proxy, and no_proxy for self-hosted runners. When the runner runs as a service, these values can be stored in the runner application's .env file. GitHub notes that the variables are read when the runner starts, so a configuration change requires a restart. GitHub's proxy guidance also calls out a common gap: container actions and service containers may require Docker-specific proxy configuration.
On a Linux runner, we configure the proxy in several places:
- the runner's
.envfile; /etc/environmentfor ordinary processes;- the Docker daemon and Docker client configuration;
- the package manager; and
- system services such as the SSM agent when they require outbound access.
This improves compatibility, but none of it replaces network enforcement. Environment variables make approved software use the proxy; security groups make bypass attempts fail.
Be equally careful with no_proxy. It should contain only destinations that genuinely need a direct path. A broad private CIDR in no_proxy is not automatically a vulnerability if security groups still block that path, but it creates confusing behavior and can become dangerous after a later network change.
Layer Four: Keep AWS Control Traffic Private
Not every required connection belongs on the internet proxy. A runner may need Systems Manager for administration, S3 for session logs or artifacts, Secrets Manager during bootstrap, ECR for images, or STS for identity. If you manage the runner through Systems Manager, provision all three of its endpoints—ssm, ssmmessages, and ec2messages—or the agent will register but Run Command and Session Manager will fail in ways that are tedious to debug.
Use gateway and interface VPC endpoints for the AWS services the runner needs. AWS documents that S3 and DynamoDB gateway endpoints do not require an internet or NAT gateway and are offered without hourly or processing charges. Interface endpoints use AWS PrivateLink and carry their own hourly and data-processing costs, but keep supported service traffic on private paths. See AWS's VPC endpoint guidance.
For interface endpoints, prefer referencing the endpoint's security group over hard-coding endpoint ENI addresses. Endpoint network interface IPs can change if an endpoint is recreated, and a stale /32 rule fails in the worst possible way—silently. Gateway endpoints for S3 are addressed by their managed prefix list:
# Allow HTTPS only to the interface endpoints' security group.
resource "aws_vpc_security_group_egress_rule" "interface_endpoints" {
security_group_id = aws_security_group.runner.id
description = "HTTPS to interface VPC endpoints only"
ip_protocol = "tcp"
from_port = 443
to_port = 443
referenced_security_group_id = var.endpoint_security_group_id
}
# S3 gateway endpoint via its managed prefix list.
resource "aws_vpc_security_group_egress_rule" "s3" {
security_group_id = aws_security_group.runner.id
description = "HTTPS to S3 via gateway endpoint"
ip_protocol = "tcp"
from_port = 443
to_port = 443
prefix_list_id = var.s3_prefix_list_id
}
Endpoint connectivity must also be constrained at higher layers. Apply least-privilege IAM to the runner role and use VPC endpoint policies where the service supports them. Otherwise, "private" can still mean "able to send stolen data to an attacker-controlled bucket or API resource."
Patching Still Has to Reach the Mirrors
There is one workload that default-deny egress breaks quietly and discovers loudly: OS patching.
If your runners are truly ephemeral—rebuilt from a freshly patched AMI for every job or on a short cycle—you largely sidestep this. Patch the image pipeline, not the instance, consistent with the immutability-first approach we've advocated before. An unpatched long-lived runner, however, is exactly the kind of soft target this architecture exists to protect, and a locked-down runner that can no longer reach its package mirrors will silently fall out of patch compliance until something forces the question.
For long-lived runners, SSM Patch Manager splits its traffic across both halves of this architecture, and it is worth being precise about which half carries what:
- The control plane stays private. The SSM agent,
AWS-RunPatchBaselineinvocations, and compliance reporting travel over thessm,ssmmessages, andec2messagesinterface endpoints from layer four. No proxy rule is required. - The packages themselves usually do not. On Ubuntu,
aptfetches from public mirrors, so those downloads must traverse the proxy—which means the package manager must be configured to use it (layer three) and the mirror domains must be on the allowlist (layer two).
For an Ubuntu runner group, the patching additions look like this:
allow_lists = {
terraform_runners = {
cidrs = ["10.20.10.0/24"]
domains = [
"github.com",
"api.github.com",
".actions.githubusercontent.com",
"codeload.github.com",
"registry.terraform.io",
"releases.hashicorp.com",
# OS patching (Ubuntu)
".archive.ubuntu.com", # covers regional EC2 mirrors, e.g. us-west-2.ec2.archive.ubuntu.com
"security.ubuntu.com",
"api.snapcraft.io", # snapd; Patch Manager does not manage snap packages
]
allow_all = false
}
}
Three notes on that list. First, the leading-dot form .archive.ubuntu.com already covers archive.ubuntu.com and every regional EC2 mirror beneath it, so separate entries for each are redundant. Second, Patch Manager does not support snap packages; api.snapcraft.io is there because snapd refreshes itself on its own schedule, and snap downloads are served from CDN domains such as .snapcraftcontent.com—watch your deny logs during the first refresh and add what your instances actually need. Third, this is the Ubuntu list; RHEL-family instances, container base images, and language runtimes each bring their own mirrors, which is another argument for building the allowlist from observed traffic rather than from memory.
One pleasant exception: Amazon Linux repositories are served from S3, so an Amazon Linux runner can patch entirely over the S3 gateway endpoint from layer four—no proxy rule, no public internet path at all. If you control the runner AMI and have no other reason to prefer Ubuntu, that is a meaningful simplification of the egress policy.
Finally, remember that the Squid fleet itself is a set of long-lived instances unless you rebuild it. Either include the proxy Auto Scaling group in the same patch group and maintenance window as other infrastructure, or—better—refresh it from updated AMIs so the control enforcing your egress policy is not the stalest box in the VPC.
Treat Denies as Security Telemetry
The proxy should be highly available enough that teams do not reach for a bypass during an outage. Our implementation places Squid instances in an Auto Scaling group behind an internal Network Load Balancer. A failed bootstrap marks the instance unhealthy, and changes to the allowlist trigger an instance refresh. Access and cache logs are shipped to CloudWatch.
The most useful events are often denied connections. Alert or investigate when:
- a runner repeatedly requests a previously unseen domain;
- a job contacts a domain unrelated to its declared build steps;
- a process attempts to connect directly instead of using the proxy;
- transfer volume to an allowed destination changes sharply; or
- a denied destination appears immediately after dependency installation.
Correlate proxy records with GitHub workflow, repository, job, and runner identity. A hostname and timestamp alone are much less useful during incident response than a hostname tied to the exact workflow and commit that generated it. VPC Flow Logs can complement proxy logs by exposing direct connection attempts that never reached Squid. And baseline the expected rhythms: an SSM maintenance window produces a legitimate burst of traffic to OS mirror domains, and an alert rule that cannot tell a patch cycle from an exfiltration attempt will be muted within a month.
What This Architecture Does—and Does Not—Stop
This pattern is strong against a common payload that calls a new command-and-control or collection domain. The payload can execute, resolve DNS, and attempt a connection, but the runner security group prevents a direct session and Squid rejects a destination outside the allowlist. That can prevent a first-stage compromise from becoming remote control or straightforward exfiltration.
It does not make arbitrary code safe.
Without TLS inspection, Squid sees the requested tunnel destination and connection metadata, not the encrypted URL, request body, or response. An attacker may try to abuse an allowlisted multi-tenant service, cloud API, or source-control endpoint. Domain allowlists also do not constrain access to private services reached through other permitted network paths.
DNS deserves specific attention. As noted earlier, security groups do not filter traffic to the VPC's Amazon-provided resolver, so name resolution keeps working even under default-deny egress—and DNS tunneling is a mature exfiltration technique with off-the-shelf tooling. It is slow and noisy compared to an HTTPS session, but it exists. Route 53 Resolver DNS Firewall can filter and log the domains a VPC is allowed to resolve, including AWS-managed lists of known-malicious domains, and pairs naturally with the proxy allowlist: a domain that Squid would never permit rarely needs to be resolvable at all.
Compensating controls still matter:
- use ephemeral, single-job runners where practical; GitHub recommends ephemeral runners for autoscaling and recommends forwarding their logs externally;
- separate runner groups by trust level and workflow purpose;
- prefer GitHub OIDC and short-lived cloud sessions over stored cloud keys;
- scope IAM roles to the resources and actions required by a workflow;
- require IMDSv2 on runner instances and set the metadata hop limit to 1 so containerized job steps cannot trivially read the instance role's credentials;
- pin actions, providers, modules, packages, and container images;
- keep
.terraform.lock.hclunder version control and verify unexpected dependency changes; - restrict which repositories and workflows can target privileged runner groups; and
- rebuild runners from known images instead of treating them as long-lived servers.
Egress filtering is one layer in this system. Its value is that it remains relevant after several earlier layers have failed.
When AWS Network Firewall Is the Better Fit
AWS Network Firewall is the managed alternative when you need controls beyond an explicit web proxy: centralized inspection across VPCs, stateful rules, managed threat-intelligence groups, broader protocol visibility, or TLS inspection. AWS supports distributed, centralized, and combined deployment models, as described in its Security Reference Architecture.
That capability has a meaningful fixed and variable cost. On AWS's current pricing page, its US East (N. Virginia) example lists a standard endpoint at $0.395 per hour per Availability Zone plus $0.065 per GB processed. The two-AZ, 5,000 GB/month example totals $893.80 per month, although eligible NAT Gateway hourly and processing charges on the same path are offset. Pricing varies by Region, inspection features, endpoint count, and traffic, so use the AWS Network Firewall pricing examples rather than treating those numbers as a quote.
For a small number of runner subnets whose main requirement is outbound HTTP and HTTPS domain control, a Squid fleet can be simpler and less expensive. Its costs are primarily EC2, the internal NLB, logging, NAT, and data transfer. It does require you to patch, scale, test, and monitor the proxy instances yourself.
A useful decision rule is:
- choose Squid when the policy is primarily "these clients may reach these web domains" and the team can operate a small proxy service;
- choose AWS Network Firewall when inspection must cover more protocols and VPCs, managed threat controls or TLS inspection are required, or a centralized network-security team values the managed control plane enough to justify the cost; and
- use both when an explicit proxy provides workload-aware web policy and Network Firewall provides broader network enforcement.
Roll Out with Evidence, Then Close the Bypass
An allowlist introduced in one step will break workflows, and a control that teams cannot use will not survive. A safer rollout is:
- Inventory runner groups, workflows, credentials, AWS APIs, registries, and external build dependencies.
- Route traffic through Squid in observation mode and collect representative logs, including infrequent release and recovery workflows and at least one full patch cycle.
- Build separate domain policies for runner groups with different purposes or trust levels.
- Replace public AWS API paths with VPC endpoints where the security and cost tradeoff makes sense.
- Enforce the allowlist and remove every direct internet egress rule from the runner security group.
- Add policy-as-code checks that fail a change introducing broad egress or
allow_all. - Test the boundary: from a workflow, verify that an approved dependency succeeds, an unapproved HTTPS domain is denied, a direct-IP connection fails, a request to an internal address through the proxy is denied, and the denial reaches your logging and alerting systems.
- Establish a reviewed, expiring process for new destinations. An emergency exception should have an owner and removal date.
The final test is important. A diagram can show traffic passing through a proxy while an overlooked NAT route, security-group rule, container network, IPv6 path, or secondary interface still provides a bypass. Test from the same execution contexts an attacker would use.
A Note on GitHub-Hosted Runners
Everything above assumes self-hosted runners, where you own the network. It is worth asking why the default experience on GitHub-hosted runners is so different.
GitHub documents that hosted runners have public-internet access by default. For supported hosted-runner configurations, customers can attach an Azure VNET and apply their own outbound policies and network monitoring, and GitHub recommends DNS or domain-based controls for that model—see GitHub's private-networking guidance and the Azure VNET configuration documentation.
Those are valuable options, but they leave a gap between an unrestricted default runner and a customer-engineered private network. To be fair, we cannot see the abuse-detection controls GitHub and Microsoft operate inside the service, and hosted runners are intentionally general-purpose—builds legitimately contact a huge and changing set of destinations, so a strict universal allowlist is unrealistic. Neither point explains the absence of an opt-in control for known-bad destinations: a customer-visible signal (or blocking mode with documented false-positive handling) that identifies connections to known command-and-control infrastructure, tied to the workflow, repository, job, and process responsible.
A compromised dependency should not be able to contact a well-known C2 domain simply because a team has not built a separate egress-security architecture. Given Microsoft's threat-intelligence and cloud-networking capabilities, this is a reasonable feature for customers to ask for—and asking for it costs nothing.
Key Takeaways
- A Terraform provider, npm install script, or GitHub Action that executes on your runner has already crossed the prevention boundary. Egress control decides what it can do next.
- Security groups, not proxy environment variables, are the enforcement point. Environment variables are cooperation; network rules are containment.
- The proxy must be hardened against becoming a pivot: deny internal destinations and the metadata service explicitly.
- Every allowlisted domain—and every resolvable domain—is a residual exfiltration path. Pair the proxy allowlist with DNS Firewall and endpoint policies.
- Denied connections are some of the highest-signal security telemetry a CI environment produces. Correlate them with workflow, repository, and commit.
- Default-deny egress breaks OS patching unless you plan for it: keep the SSM control plane on VPC endpoints, put package mirrors on the proxy allowlist for long-lived runners, or make the problem disappear with ephemeral runners rebuilt from patched images.
Assume Execution. Constrain Outcomes.
Supply-chain defense cannot depend on identifying every malicious package, provider, action, or maintainer compromise before it reaches CI. The ecosystem moves too quickly, trusted projects change hands, and malicious behavior can hide inside otherwise legitimate code.
That does not mean defenders are powerless once code executes.
A runner with unrestricted outbound access gives an unexpected binary the entire internet as its second stage. A runner with enforced proxy-only egress, narrowly scoped destinations, private AWS endpoints, limited credentials, and useful telemetry gives it a much smaller box.
Prevention asks, "How do we stop malicious code from running?"
Containment adds the question that matters when prevention fails: "If it runs, where can it go?"
Featured Blog Posts
Explore our latest blog posts on cybersecurity vulnerabilities.
Ready to Reduce Cloud Security Noise and Act Faster?
Discover the power of Averlon’s AI-driven insights. Identify and prioritize real threats faster and drive a swift, targeted response to regain control of your cloud. Shrink the time to resolution for critical risk by up to 90%.




.png)