From 64af6c5d086e67065304438f0809aa6658f2d807 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Sat, 4 Apr 2026 21:41:34 -0600 Subject: [PATCH 1/2] refactor: use ironsh/iron-proxy-action marketplace action Replace manual proxy setup with the ironsh/iron-proxy-action GitHub Action, which handles installation, CA generation, DNS, and iptables configuration automatically. --- .github/workflows/ci.yaml | 67 +----------- README.md | 225 ++++++++++---------------------------- egress-rules.yaml | 13 +++ iron-proxy.yaml | 32 ------ 4 files changed, 75 insertions(+), 262 deletions(-) create mode 100644 egress-rules.yaml delete mode 100644 iron-proxy.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bddf87d..aba4727 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,64 +12,9 @@ jobs: steps: - uses: actions/checkout@v4 - # Setup steps for iron-proxy - - name: Start iron-proxy - run: | - # Download and install iron-proxy - echo "=== Installing iron-proxy ===" - export VERSION=0.4.0 - curl -fsSL -o /tmp/iron-proxy.tgz \ - https://github.com/ironsh/iron-proxy/releases/download/v${VERSION}/iron-proxy_${VERSION}_linux_amd64.tar.gz - tar -xzf /tmp/iron-proxy.tgz -C /tmp - sudo mv /tmp/iron-proxy /usr/local/bin/iron-proxy - sudo chmod +x /usr/local/bin/iron-proxy - - # Generate a CA for TLS interception. Must have keyUsage=critical,keyCertSign and CA constraints - echo "=== Generating CA for TLS interception ===" - mkdir -p /tmp/iron-proxy-ca - openssl genrsa -out /tmp/iron-proxy-ca/ca.key 2048 2>/dev/null - openssl req -x509 -new -nodes \ - -key /tmp/iron-proxy-ca/ca.key \ - -sha256 -days 1 \ - -subj "/CN=iron-proxy CA" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -addext "keyUsage=critical,keyCertSign" \ - -out /tmp/iron-proxy-ca/ca.crt 2>/dev/null - - # Trust the CA system-wide, and within Node.js. Some tools require extra config - echo "=== Trusting CA system-wide ===" - sudo cp /tmp/iron-proxy-ca/ca.crt \ - /usr/local/share/ca-certificates/iron-proxy-ca.crt - sudo update-ca-certificates - echo "NODE_EXTRA_CA_CERTS=/tmp/iron-proxy-ca/ca.crt" >> $GITHUB_ENV - - # Stop systemd-resolved. Required to allow iron-proxy to handle DNS resolution - echo "=== Stopping systemd-resolved ===" - sudo systemctl stop systemd-resolved || true - - # Start iron-proxy. Use setsid to run it in a new session so it is not - # killed when the run: block's shell exits. - echo "=== Starting iron-proxy ===" - sudo install -m 644 -o root -g root /dev/null /var/log/iron-proxy.log - sudo setsid bash -c '/usr/local/bin/iron-proxy -config ./iron-proxy.yaml &>/var/log/iron-proxy.log' & - sleep 0.5 - - # Delete the key from disk now that it's in memory - rm /tmp/iron-proxy-ca/ca.key - - # Route DNS through the proxy - echo "=== Routing DNS through the proxy ===" - sudo bash -c 'echo "nameserver 127.0.0.1" > /etc/resolv.conf' - - # Lock down outbound traffic with iptables. Only loopback traffic (how - # processes reach the proxy), DNS to the upstream resolver, and traffic - # from root (how the proxy reaches the internet) are allowed. Everything - # else is rejected. - echo "=== Configuring iptables ===" - sudo iptables -A OUTPUT -o lo -j ACCEPT - sudo iptables -A OUTPUT -m owner --uid-owner root -j ACCEPT - sudo iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT - sudo iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable + - uses: ironsh/iron-proxy-action@v0.1.0 + with: + egress-rules: egress-rules.yaml # Your build steps (all traffic goes through iron-proxy) - name: Install dependencies @@ -78,9 +23,5 @@ jobs: - name: Run tests run: npm test - - name: Print proxy log + - uses: ironsh/iron-proxy-action/summary@v0.1.0 if: always() - run: | - echo "=== Proxy log ===" - cat /var/log/iron-proxy.log | grep --line-buffered '^{' | \ - jq -r '[(.time | split(".")[0]), .audit.action, .audit.host, .audit.method, .audit.path] | @tsv' diff --git a/README.md b/README.md index b453b4d..49649f9 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,90 @@ # Hosted Actions Example: Using iron-proxy in GitHub Actions -[iron-proxy](https://github.com/ironsh/iron-proxy) is a transparent forward proxy that intercepts all HTTP and HTTPS traffic from your CI job and enforces a domain allowlist. This repository is a working example you can copy and adapt. +[iron-proxy](https://github.com/ironsh/iron-proxy) is a transparent forward proxy that intercepts all HTTP and HTTPS traffic from your CI job and enforces a domain allowlist. The [`ironsh/iron-proxy-action`](https://github.com/marketplace/actions/iron-proxy) GitHub Action handles all the setup for you. This repository is a working example you can copy and adapt. ## Quick Start -1. Copy [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) and [`iron-proxy.yaml`](iron-proxy.yaml) into your repository. -2. Replace the commented build steps (`npm ci`, `npm test`) with your own. +1. Copy [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) and [`egress-rules.yaml`](egress-rules.yaml) into your repository. +2. Replace the build steps (`npm ci`, `npm test`) with your own. 3. Push and let the workflow run. It will probably fail because your build contacts hosts that aren't in the allowlist yet. -4. Check the "Print proxy log" step in the workflow output. It shows every request iron-proxy handled, including blocked ones. -5. Add the blocked domains to the `domains` list in `iron-proxy.yaml`, commit, and re-run. Repeat until your build passes. +4. Check the job summary produced by the `ironsh/iron-proxy-action/summary` step. It shows every request iron-proxy handled, including blocked ones. +5. Add the blocked domains to the `domains` list in `egress-rules.yaml`, commit, and re-run. Repeat until your build passes. That's it. Everything below explains what is happening under the hood. -> **Security note:** GitHub Actions gives build jobs `sudo`. Attackers can therefore circumvent egress enforcement if they detect it. For this reason, we strongly recommend using self-hosted runners in VMs and performing egress enforcement at the hypervisor level. +> **Security note:** GitHub Actions gives build jobs `sudo` by default. The action revokes `sudo` for subsequent steps (controlled by the `disable-sudo` input) so that build scripts cannot bypass the proxy. For stronger isolation, we recommend using self-hosted runners in VMs and performing egress enforcement at the hypervisor level. -## Configuring the Allowlist +## Workflow -The proxy configuration lives in [`iron-proxy.yaml`](iron-proxy.yaml). The key section is the `transforms` block: +The workflow uses the [`ironsh/iron-proxy-action`](https://github.com/marketplace/actions/iron-proxy) action, which handles downloading iron-proxy, generating and trusting a TLS interception CA, configuring DNS, starting the proxy, and locking down outbound traffic with iptables — all in a single step: ```yaml -transforms: - - name: allowlist - config: - domains: - # GitHub Actions infrastructure - - "github.com" - - "*.github.com" - - "*.githubusercontent.com" - - "*.actions.githubusercontent.com" - - "*.pkg.github.com" - - "*.blob.core.windows.net" - - "api.github.com" - # Stuff your build needs - - "nodejs.org" - - "*.nodejs.org" - - "registry.npmjs.org" - - "*.npmjs.org" +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: ironsh/iron-proxy-action@v0.1.0 + with: + egress-rules: egress-rules.yaml + + # Your build steps (all traffic goes through iron-proxy) + - run: npm ci + - run: npm test + + - uses: ironsh/iron-proxy-action/summary@v0.1.0 + if: always() ``` -**`domains`** lists hostnames (with optional wildcards) that are allowed through the proxy. Everything else is blocked. +The `summary` step runs at the end (even on failure) and produces a job summary showing all allowed and denied requests. + +### Action Inputs -## Viewing the Proxy Log +| Input | Default | Description | +|-------|---------|-------------| +| `egress-rules` | `egress-rules.yaml` | Path to your egress rules file | +| `version` | `latest` | Iron proxy version to install | +| `warn` | `false` | Log denied requests without blocking them | +| `disable-sudo` | `true` | Revoke sudo so subsequent steps can't bypass the proxy | +| `disable-docker` | `true` | Revoke Docker access so subsequent steps can't bypass the proxy | +| `upstream-resolver` | `8.8.8.8:53` | Upstream DNS resolver | -The workflow includes a final step that always runs, even if earlier steps fail: +## Configuring the Allowlist + +The egress rules live in [`egress-rules.yaml`](egress-rules.yaml): ```yaml -- name: Print proxy log - if: always() - run: | - echo "=== Proxy log ===" - cat /var/log/iron-proxy.log | grep --line-buffered '^{' | \ - jq -r '[(.time | split(".")[0]), .audit.action, .audit.host, .audit.method, .audit.path] | @tsv' +domains: + # GitHub Actions infrastructure + - "github.com" + - "*.github.com" + - "*.githubusercontent.com" + - "*.actions.githubusercontent.com" + - "*.pkg.github.com" + - "*.blob.core.windows.net" + - "api.github.com" + # Stuff your build needs + - "nodejs.org" + - "*.nodejs.org" + - "*.npmjs.org" ``` -iron-proxy writes structured JSON logs. This command extracts the timestamp, action (`allow` or `deny`), host, HTTP method, and path into a readable table. When something is blocked, this log is the fastest way to find out which domain you need to add. - ---- +**`domains`** lists hostnames (with optional wildcards) that are allowed through the proxy. Everything else is blocked. ## How It Works iron-proxy sits between your CI job and the internet. It has four responsibilities: 1. **DNS interception.** iron-proxy runs a DNS server on `127.0.0.1:53`. When any process resolves a hostname, iron-proxy returns `127.0.0.1`, directing the connection back through itself. It forwards the real lookup to an upstream resolver (`8.8.8.8` by default) to connect to the actual destination. -2. **TLS interception.** For HTTPS, iron-proxy generates certificates on the fly for each destination host, signed by a short-lived CA that the workflow creates and trusts. Tools like `curl`, `npm`, and `apt` accept these certificates because the CA is in the system trust store. -3. **Allowlist enforcement.** Each request is checked against the domain and CIDR lists in `iron-proxy.yaml`. Requests to unlisted hosts are blocked and logged. -4. **Network lockdown.** iptables rules prevent any process from bypassing the proxy by connecting to an external IP directly. Only root (the user iron-proxy runs as) and already-established connections are allowed to make outbound connections. All other processes must go through loopback, where the proxy is listening. - -## Detailed Walkthrough - -The entire proxy setup happens inside a single `run:` block in the workflow. Here is each piece, in order. - -### Download and Install iron-proxy - -```bash -# Download and install iron-proxy -echo "=== Installing iron-proxy ===" -export VERSION=0.4.0 -curl -fsSL -o /tmp/iron-proxy.tgz \ - https://github.com/ironsh/iron-proxy/releases/download/v${VERSION}/iron-proxy_${VERSION}_linux_amd64.tar.gz -tar -xzf /tmp/iron-proxy.tgz -C /tmp -sudo mv /tmp/iron-proxy /usr/local/bin/iron-proxy -sudo chmod +x /usr/local/bin/iron-proxy -``` - -Downloads a pinned release of iron-proxy and places it on the `PATH`. - -### Generate a CA for TLS Interception - -```bash -# Generate a CA for TLS interception. Must have keyUsage=critical,keyCertSign and CA constraints -echo "=== Generating CA for TLS interception ===" -mkdir -p /tmp/iron-proxy-ca -openssl genrsa -out /tmp/iron-proxy-ca/ca.key 2048 2>/dev/null -openssl req -x509 -new -nodes \ - -key /tmp/iron-proxy-ca/ca.key \ - -sha256 -days 1 \ - -subj "/CN=iron-proxy CA" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -addext "keyUsage=critical,keyCertSign" \ - -out /tmp/iron-proxy-ca/ca.crt 2>/dev/null -``` - -iron-proxy needs a CA certificate and key to generate per-host TLS certificates on the fly. A few details matter here: - -- **`basicConstraints=critical,CA:TRUE`** marks the certificate as a CA. Without this, TLS implementations will reject any certificates it signs. -- **`keyUsage=critical,keyCertSign`** grants permission to sign other certificates. iron-proxy cannot issue per-host certs without this. -- **`-days 1`** gives the CA a one-day lifetime. It only needs to survive a single CI run, so keeping it short limits exposure. -- **`-nodes`** leaves the private key unencrypted so iron-proxy can read it without a passphrase. - -The CA is ephemeral: created fresh on every run and discarded when the runner is torn down. - -### Trust the CA - -```bash -# Trust the CA system-wide, and within Node.js. Some tools require extra config -echo "=== Trusting CA system-wide ===" -sudo cp /tmp/iron-proxy-ca/ca.crt \ - /usr/local/share/ca-certificates/iron-proxy-ca.crt -sudo update-ca-certificates -echo "NODE_EXTRA_CA_CERTS=/tmp/iron-proxy-ca/ca.crt" >> $GITHUB_ENV -``` - -For TLS interception to work transparently, tools in your CI job need to trust the CA: - -- **System trust store:** `update-ca-certificates` adds the CA to the system bundle. This covers tools that use OpenSSL or the system certificate store, including `curl`, `wget`, and `apt`. -- **Node.js:** Node.js ships its own certificate bundle and ignores the system store. Setting `NODE_EXTRA_CA_CERTS` tells it to trust the CA as well. Writing it to `$GITHUB_ENV` makes it available in all subsequent workflow steps. - -> **Note:** Other runtimes may need their own configuration. For example, Python's `requests` library respects `REQUESTS_CA_BUNDLE`, and Java uses a keystore that can be updated with `keytool`. - -### Stop systemd-resolved - -```bash -# Stop systemd-resolved. Required to allow iron-proxy to handle DNS resolution -echo "=== Stopping systemd-resolved ===" -sudo systemctl stop systemd-resolved || true -``` - -On Ubuntu runners, `systemd-resolved` manages DNS and listens on port 53. iron-proxy needs that port for its own DNS server. Stopping `systemd-resolved` frees it up. - -### Start iron-proxy with setsid - -```bash -# Start iron-proxy. Use setsid to run it in a new session so it is not -# killed when the run: block's shell exits. -echo "=== Starting iron-proxy ===" -sudo install -m 644 -o root -g root /dev/null /var/log/iron-proxy.log -sudo setsid bash -c '/usr/local/bin/iron-proxy -config ./iron-proxy.yaml &>/var/log/iron-proxy.log' & -sleep 0.5 -``` - -iron-proxy runs as root so it can bind to privileged ports (53, 80, 443) without extra configuration. - -**`setsid`** starts iron-proxy in a new session, fully detached from the shell's process group. This is critical: GitHub Actions sends signals (like `SIGHUP`) to the shell's process group when a `run:` block ends. Without `setsid`, iron-proxy would be killed between steps. `setsid` moves it into its own session so it survives for the entire workflow. - -### Delete the CA Key from Disk - -```bash -# Delete the key from disk now that it's in memory -rm /tmp/iron-proxy-ca/ca.key -``` - -Once iron-proxy has loaded the CA key into memory, the file on disk is no longer needed. Deleting it limits the window during which a compromised dependency or build script could read it. iron-proxy continues to use the key from memory for the rest of the run. - -### Route DNS Through the Proxy - -```bash -# Route DNS through the proxy -echo "=== Routing DNS through the proxy ===" -sudo bash -c 'echo "nameserver 127.0.0.1" > /etc/resolv.conf' -``` - -This overwrites `/etc/resolv.conf` so all DNS queries go to `127.0.0.1`, where iron-proxy is listening. From this point forward, every hostname resolution goes through the proxy. - -### Lock Down Outbound Traffic with iptables - -```bash -# Lock down outbound traffic with iptables. Only loopback traffic (how -# processes reach the proxy), DNS to the upstream resolver, and traffic -# from root (how the proxy reaches the internet) are allowed. Everything -# else is rejected. -echo "=== Configuring iptables ===" -sudo iptables -A OUTPUT -o lo -j ACCEPT -sudo iptables -A OUTPUT -m owner --uid-owner root -j ACCEPT -sudo iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -sudo iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable -``` - -This is the final layer of enforcement. DNS redirection alone can be bypassed if a process connects to a hardcoded IP address or uses its own DNS resolver. The iptables rules on the `OUTPUT` chain close that gap: +2. **TLS interception.** For HTTPS, iron-proxy generates certificates on the fly for each destination host, signed by a short-lived CA that the action creates and trusts. Tools like `curl`, `npm`, and `apt` accept these certificates because the CA is in the system trust store. +3. **Allowlist enforcement.** Each request is checked against the domain list in `egress-rules.yaml`. Requests to unlisted hosts are blocked and logged. +4. **Network lockdown.** iptables rules prevent any process from bypassing the proxy by connecting to an external IP directly. Only the proxy and already-established connections are allowed to make outbound connections. All other processes must go through loopback, where the proxy is listening. -1. **`-o lo -j ACCEPT`** allows all traffic on the loopback interface. This is how every process on the runner reaches the proxy (since DNS resolves all hosts to `127.0.0.1`). -2. **`-m owner --uid-owner root -j ACCEPT`** allows root to make outbound connections. iron-proxy runs as root, so this is what lets the proxy reach the real internet on behalf of proxied clients. -3. **`-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`** allows packets on connections that were already open before the rules were applied. This keeps the GitHub Actions runner's pre-existing control connection to the Actions service alive. Without this, the runner could lose contact with GitHub and the job would hang. -4. **`-j REJECT --reject-with icmp-port-unreachable`** rejects everything else with an immediate error. +## Known Limitations -With these rules in place, a process that tries to `curl` a raw IP address (bypassing DNS entirely) will get an immediate connection error instead of reaching the internet. +- GitHub Actions gives build jobs `sudo` by default. The action revokes `sudo` for subsequent steps, but if you set `disable-sudo: false`, attackers who detect the proxy could circumvent it. +- Some runtimes ship their own certificate bundles and may need extra configuration to trust the interception CA. The action handles Node.js (`NODE_EXTRA_CA_CERTS`) automatically; others like Python's `requests` (`REQUESTS_CA_BUNDLE`) or Java (`keytool`) may need manual setup. ## License diff --git a/egress-rules.yaml b/egress-rules.yaml new file mode 100644 index 0000000..f52db8b --- /dev/null +++ b/egress-rules.yaml @@ -0,0 +1,13 @@ +domains: + # GitHub Actions infrastructure + - "github.com" + - "*.github.com" + - "*.githubusercontent.com" + - "*.actions.githubusercontent.com" + - "*.pkg.github.com" + - "*.blob.core.windows.net" + - "api.github.com" + # Stuff your build needs + - "nodejs.org" + - "*.nodejs.org" + - "*.npmjs.org" diff --git a/iron-proxy.yaml b/iron-proxy.yaml deleted file mode 100644 index 926ca0d..0000000 --- a/iron-proxy.yaml +++ /dev/null @@ -1,32 +0,0 @@ -dns: - listen: ":53" - proxy_ip: "127.0.0.1" - upstream_resolver: "8.8.8.8:53" - -proxy: - http_listen: ":80" - https_listen: ":443" - -tls: - ca_cert: "/tmp/iron-proxy-ca/ca.crt" - ca_key: "/tmp/iron-proxy-ca/ca.key" - -transforms: - - name: allowlist - config: - domains: - # GitHub Actions infrastructure - - "github.com" - - "*.github.com" - - "*.githubusercontent.com" - - "*.actions.githubusercontent.com" - - "*.pkg.github.com" - - "*.blob.core.windows.net" - - "api.github.com" - # Stuff your build needs - - "nodejs.org" - - "*.nodejs.org" - - "*.npmjs.org" - -log: - level: "info" From 87d0ebb5aac969703fcfd2afc6aeea0f9c885154 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Sat, 4 Apr 2026 21:43:48 -0600 Subject: [PATCH 2/2] docs: recommend warn mode for establishing initial allowlist --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 49649f9..f85cbdc 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ 1. Copy [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) and [`egress-rules.yaml`](egress-rules.yaml) into your repository. 2. Replace the build steps (`npm ci`, `npm test`) with your own. -3. Push and let the workflow run. It will probably fail because your build contacts hosts that aren't in the allowlist yet. -4. Check the job summary produced by the `ironsh/iron-proxy-action/summary` step. It shows every request iron-proxy handled, including blocked ones. -5. Add the blocked domains to the `domains` list in `egress-rules.yaml`, commit, and re-run. Repeat until your build passes. +3. Set `warn: true` on the action and push. The build will pass normally while the proxy logs every outbound request without blocking anything. +4. Check the job summary produced by the `ironsh/iron-proxy-action/summary` step. It shows every domain your build contacted. +5. Add those domains to the `domains` list in `egress-rules.yaml`, remove `warn: true`, and push again. The proxy will now enforce the allowlist. That's it. Everything below explains what is happening under the hood.