{"id":47731359,"url":"https://github.com/ironsh/iron-proxy","last_synced_at":"2026-05-25T22:03:37.945Z","repository":{"id":348577401,"uuid":"1198724226","full_name":"ironsh/iron-proxy","owner":"ironsh","description":"An egress firewall for untrusted workloads.","archived":false,"fork":false,"pushed_at":"2026-05-18T22:35:45.000Z","size":2853,"stargazers_count":350,"open_issues_count":0,"forks_count":15,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-05-19T00:57:21.867Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://docs.iron.sh","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ironsh.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-01T17:40:30.000Z","updated_at":"2026-05-18T22:35:50.000Z","dependencies_parsed_at":"2026-04-08T02:00:31.153Z","dependency_job_id":null,"html_url":"https://github.com/ironsh/iron-proxy","commit_stats":null,"previous_names":["ironsh/iron-proxy"],"tags_count":43,"template":false,"template_full_name":null,"purl":"pkg:github/ironsh/iron-proxy","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ironsh%2Firon-proxy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ironsh%2Firon-proxy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ironsh%2Firon-proxy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ironsh%2Firon-proxy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ironsh","download_url":"https://codeload.github.com/ironsh/iron-proxy/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ironsh%2Firon-proxy/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33387661,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T04:15:53.637Z","status":"ssl_error","status_checked_at":"2026-05-23T04:15:53.242Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-02T21:35:39.552Z","updated_at":"2026-05-23T08:05:10.531Z","avatar_url":"https://github.com/ironsh.png","language":"Go","funding_links":[],"categories":["Go","Point-of-use validations","Secrets Management \u0026 Isolation"],"sub_categories":["Vulnerability information exchange"],"readme":"# iron-proxy\n\n[![Docs](https://img.shields.io/badge/docs-iron--proxy-blue)](https://docs.iron.sh)\n[![Latest Release](https://img.shields.io/github/v/release/ironsh/iron-proxy)](https://github.com/ironsh/iron-proxy/releases/latest)\n[![Docker Pulls](https://img.shields.io/docker/pulls/ironsh/iron-proxy)](https://hub.docker.com/r/ironsh/iron-proxy)\n\n## The problem\n\nCI jobs, AI coding agents, and sandboxed containers can make arbitrary outbound\nrequests. A compromised dependency, a prompt injection, or a malicious build\nstep can exfiltrate secrets, phone home, or open a reverse shell. Most\nteams have zero visibility into what's leaving their workloads, let alone any\nway to stop it.\n\n## What iron-proxy does\n\niron-proxy is a MITM egress proxy with a built-in DNS server that sits between\nyour untrusted workload and the internet. It enforces default-deny at the\nnetwork boundary, so the workload can only reach domains you explicitly allow.\nReal secrets never enter the sandbox. Workloads use proxy tokens, and\niron-proxy swaps in real credentials at egress, meaning a compromised workload\ncan exfiltrate a token that's worthless outside the proxy.\n\nSingle binary. Single YAML config.\n\n- **Default-deny egress.** Every outbound request is blocked unless the\n  destination matches your allowlist. List your domains and CIDRs, everything\n  else gets a 403.\n- **Upstream IP deny list.** Even when a host is allowed, the proxy refuses\n  to dial it if its resolved address falls inside a denied CIDR — closing\n  the SSRF/DNS-rebinding gap where an allowlisted hostname points at IMDS\n  or loopback. Cloud metadata endpoints (`169.254.169.254`) and loopback are\n  denied by default; override via `proxy.upstream_deny_cidrs`.\n- **Boundary-level secret injection.** Workloads send proxy tokens; iron-proxy\n  replaces them with real secrets before the request leaves. If the sandbox is\n  compromised, the attacker gets tokens that are useless outside the proxy.\n- **Per-request audit trail.** Every request logged as structured JSON with\n  the full transform pipeline result: which secrets were swapped, which rules\n  matched, what got blocked and why.\n- **Streaming-aware.** WebSocket upgrades and Server-Sent Events are proxied\n  natively. No special configuration for agent workloads that hold long-lived\n  connections.\n- **CONNECT and SOCKS5 support.** Optional tunnel listener for tools that\n  natively support proxy configuration via `HTTPS_PROXY` or SOCKS5 settings.\n- **PostgreSQL MITM proxy.** Optional listener that authenticates clients\n  against proxy-managed credentials, injects `SET ROLE` on the upstream\n  session, and rejects client attempts to mutate the role (`SET ROLE`,\n  `set_config('role', ...)`, DO blocks, etc.) via a SQL AST walk. Pairs with\n  PostgreSQL row-level security to give per-tenant data isolation when the\n  application connects as a shared service-account user. **Requires\n  PgBouncer (if used) to run in `pool_mode = session`** — transaction or\n  statement pool modes silently rebind backends between queries and would\n  defeat the policy. See [docs.iron.sh](https://docs.iron.sh) for details.\n\nBuilt for CI pipelines, GitHub Actions, AI agents (Claude Code, Cursor,\nCodex), and any environment where you run code you don't fully trust.\n\n\u003cdiv align=\"center\"\u003e\n    \u003cstrong\u003eBlocked exfiltration + secret rewriting in action:\u003c/strong\u003e\n    \u003cbr/\u003e\u003cbr/\u003e\n    \u003ca href=\"https://screen.studio/share/Gq2zqtrp\" target=\"_blank\"\u003e\n        \u003cimg src=\"./images/intro.gif\" width=\"75%\" /\u003e\n    \u003c/a\u003e\n\u003c/div\u003e\n \n## Installation\n \nDocker images are available on [Docker Hub](https://hub.docker.com/r/ironsh/iron-proxy)\nand pre-built binaries for Linux/macOS (amd64/arm64) are on\n[GitHub Releases](https://github.com/ironsh/iron-proxy/releases).\n \nOr build from source:\n \n```bash\ngo build -o iron-proxy ./cmd/iron-proxy\n```\n\n## Quick start\n\n```bash\ncd examples/docker-compose\ndocker compose up\n```\n\nThis starts iron-proxy and a demo client that fires five requests through the\nproxy. Check the logs to see allowed, blocked, and secret-rewritten requests:\n\n```bash\ndocker compose logs proxy\n```\n\nEvery request produces a structured JSON audit entry:\n\n```json\n{\n  \"host\": \"httpbin.org\",\n  \"method\": \"GET\",\n  \"path\": \"/headers\",\n  \"action\": \"allow\",\n  \"status_code\": 200,\n  \"duration_ms\": 142,\n  \"request_transforms\": [\n    { \"name\": \"allowlist\", \"action\": \"continue\" },\n    {\n      \"name\": \"secrets\",\n      \"action\": \"continue\",\n      \"annotations\": { \"swapped\": [{ \"secret\": \"OPENAI_API_KEY\", \"locations\": [\"header:Authorization\"] }] }\n    }\n  ]\n}\n```\n\nRejected requests include a `rejected_by` field and log at WARN level. See\n[Audit log format](#audit-log-format) for the full schema.\n\n## Production usage\n\n### 1. Generate a CA\n\niron-proxy terminates TLS by generating leaf certificates on the fly, signed by\na CA you provide. Client containers must trust this CA.\n\n```bash\nmkdir -p certs\nopenssl genrsa -out certs/ca.key 4096\nopenssl req -x509 -new -nodes \\\n    -key certs/ca.key \\\n    -sha256 -days 3650 \\\n    -subj \"/CN=iron-proxy CA\" \\\n    -addext \"basicConstraints=critical,CA:TRUE\" \\\n    -addext \"keyUsage=critical,keyCertSign\" \\\n    -out certs/ca.crt\n```\n\n### 2. Create a Docker network\n\niron-proxy needs a fixed IP so containers can point their DNS at it:\n\n```bash\ndocker network create --subnet=172.20.0.0/24 iron-proxy\n```\n\n### 3. Start iron-proxy\n\nCreate an env file with your secrets (keep this out of version control):\n\n```bash\necho \"OPENAI_API_KEY=sk-real-key\" \u003e .env\n```\n\n```bash\ndocker run -d --name iron-proxy \\\n  --network iron-proxy --ip 172.20.0.2 \\\n  -v $(pwd)/proxy.yaml:/etc/iron-proxy/proxy.yaml:ro \\\n  -v $(pwd)/certs/ca.crt:/etc/iron-proxy/ca.crt:ro \\\n  -v $(pwd)/certs/ca.key:/etc/iron-proxy/ca.key:ro \\\n  --env-file .env \\\n  ironsh/iron-proxy:latest -config /etc/iron-proxy/proxy.yaml\n```\n\n### 4. Route containers through the proxy\n\nThe simplest approach is DNS-based routing: point the container's DNS at\niron-proxy and all hostname lookups resolve to the proxy IP, routing traffic\nthrough it automatically:\n\n```bash\ndocker run --rm \\\n  --network iron-proxy \\\n  --dns 172.20.0.2 \\\n  -v $(pwd)/certs/ca.crt:/certs/ca.crt:ro \\\n  curlimages/curl --cacert /certs/ca.crt https://httpbin.org/get\n```\n\nFor stronger enforcement, layer nftables rules to block non-proxy egress, or use\nTPROXY for kernel-level interception. See [Routing traffic to the\nproxy](#routing-traffic-to-the-proxy) for details on each approach.\n\n## Why iron-proxy?\n\n|                          | iron-proxy                     | Squid                       | mitmproxy                 | Envoy                              |\n| ------------------------ | ------------------------------ | --------------------------- | ------------------------- | ---------------------------------- |\n| Default-deny egress      | Built-in                       | Requires complex ACL config | Requires custom scripting | Requires RBAC/filter configuration |\n| Secret injection         | Built-in                       | No                          | No                        | No                                 |\n| Structured audit logging | Built-in, per-transform traces | Basic access logs           | Plugin-based              | Configurable access logs           |\n| Setup complexity         | Single binary + YAML           | Extensive config language   | Python scripting          | Complex YAML or control plane      |\n\niron-proxy is purpose-built for one job: controlling and auditing egress from\nuntrusted workloads. Squid can do default-deny but requires significant ACL\nconfiguration and has no concept of secret injection. mitmproxy is a great\ndebugging tool but isn't designed for production enforcement. Envoy is a\ngeneral-purpose proxy that can be configured to do parts of this, but it's\nfar more complexity than the problem requires.\n\n## How it works\n\niron-proxy runs a DNS server and an HTTP/HTTPS proxy. Point your container's DNS\nat iron-proxy and all hostname lookups resolve to the proxy IP, routing traffic\nthrough it automatically. The proxy terminates TLS (generating leaf certs on the\nfly from a CA you provide), runs the request through an ordered transform\npipeline, forwards it upstream, and runs the response back through the pipeline.\n\n```\nContainer → DNS lookup → iron-proxy IP → TLS termination → transforms → upstream\n```\n\nTransforms run in order. Built-in transforms:\n\n| Transform   | What it does                                                                                                            |\n| ----------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `allowlist`    | Permits requests to matching domains/CIDRs; rejects everything else (403).                                              |\n| `secrets`      | Scans headers (and optionally query, path, or body) for proxy tokens and swaps in real secrets from environment variables. |\n| `body_capture` | Records decoded request bodies of matching hosts as `request_body` audit fields. Observation-only; never rejects.       |\n\n## Configuration\n\niron-proxy takes a single flag: `-config path/to/config.yaml`. Here's the\nfull shape (see [`iron-proxy.example.yaml`](iron-proxy.example.yaml) for a\ncopy-pasteable starting point):\n\n```yaml\ndns:\n  listen: \":53\"\n  proxy_ip: \"10.16.0.1\" # IP where iron-proxy is running (required)\n  passthrough: # Domains forwarded to OS resolver\n    - \"*.internal.corp\"\n    - \"metadata.google.internal\"\n  records: # Static DNS records (highest precedence)\n    - name: \"internal.example.com\"\n      type: A\n      value: \"10.0.0.5\"\n\nproxy:\n  http_listen: \":80\"\n  https_listen: \":443\"\n  tunnel_listen: \":8080\" # Optional CONNECT/SOCKS5 listener\n  max_request_body_bytes: 1048576 # 1 MiB (default)\n  max_response_body_bytes: 0 # uncapped (default)\n\ntls:\n  ca_cert: \"/etc/iron-proxy/ca.crt\" # Required\n  ca_key: \"/etc/iron-proxy/ca.key\" # Required\n  cert_cache_size: 1000 # LRU cache for generated leaf certs\n  leaf_cert_expiry_hours: 72\n\ntransforms:\n  - name: allowlist\n    config:\n      domains:\n        - \"api.openai.com\"\n        - \"*.anthropic.com\"\n      cidrs:\n        - \"10.0.0.0/8\"\n\n  - name: secrets\n    config:\n      secrets:\n        - source:\n            type: env\n            var: OPENAI_API_KEY # Env var holding the real secret\n          proxy_value: \"proxy-token-123\" # Token the sandbox sends\n          match_headers: [\"Authorization\"]\n          match_body: false\n          require: true # Reject requests without the proxy token\n          rules:\n            - host: \"api.openai.com\"\n\nlog:\n  level: \"info\" # debug, info, warn, error\n```\n\n### DNS\n\nEverything resolves to `proxy_ip` by default, which is what routes traffic\nthrough the proxy. Exceptions:\n\n- **`passthrough`:** glob patterns forwarded to the OS resolver (e.g.,\n  `*.internal.corp`). Traffic to these hosts bypasses the proxy entirely.\n- **`records`:** static A or CNAME records. Highest precedence.\n\n### Allowlist\n\nDefault-deny. Requests must match at least one domain glob or CIDR to proceed.\nUnmatched requests get a `403 Forbidden`.\n\nDomain patterns use glob matching: `*.example.com` matches any subdomain and\n`example.com` itself.\n\n**Warn mode:** Set `warn: true` to observe what the allowlist would block without\nactually enforcing it. Requests that would be rejected are allowed through but\nannotated with `\"action\": \"warn\"` in the transform trace. This is useful for\nrolling out new allowlist rules or auditing existing traffic before switching\nto enforcement.\n\n### Annotate\n\nCaptures HTTP request headers into audit log annotations based on\nhost/method/path rules. This is useful for enriching audit logs with\nrequest-specific context like request IDs without modifying the proxy core.\n\nEach annotation group specifies rules to match and headers to capture. When a\nrequest matches any rule in a group, the specified header values are written as\n`header:\u003cName\u003e` entries in the transform trace annotations. Requests that don't\nmatch are passed through unchanged. This transform never rejects requests.\n\n\u003e **Warning:** Header values are emitted in plain text in the audit log. Only\n\u003e log headers that are safe to expose, such as request IDs or headers containing\n\u003e proxy secret tokens. Do not log headers that contain raw secrets.\n\n```yaml\ntransforms:\n  - name: annotate\n    config:\n      annotations:\n        - rules:\n            - host: \"api.openai.com\"\n              methods: [\"POST\"]\n              paths: [\"/v1/*\"]\n          headers: [\"x-request-id\"]\n        - rules:\n            - host: \"*.anthropic.com\"\n          headers: [\"x-request-id\"]\n```\n\n### Header allowlist\n\nDefault-deny request header filter. Any request header whose canonical name is\nnot in the configured `headers` list is stripped before the request goes\nupstream. Useful for blocking tracking, fingerprinting, or accidental leakage\nheaders (cookies, internal correlation IDs, `X-Forwarded-*`, etc.) that the\nsandbox might attach.\n\nEntries are matched case-insensitively against the canonical header name.\nPatterns delimited by `/.../` (e.g. `/^X-Trace-.*$/`) are case-insensitive\nregular expressions, mirroring the `secrets` transform's `match_headers`\nsyntax.\n\nOptional `rules` limit the allowlist to specific hosts/methods/paths. When\nomitted, the allowlist applies to every request that reaches this transform.\n\nWhen at least one header is stripped, the trace is annotated with\n`stripped_headers` listing the removed names.\n\n\u003e **Placement:** put `header_allowlist` *after* `secrets` (so injected\n\u003e credentials are not stripped if not in the allowlist, you can list them) and\n\u003e *after* `annotate` (so annotation reads the original headers).\n\n```yaml\ntransforms:\n  - name: header_allowlist\n    config:\n      headers:\n        - \"Authorization\"\n        - \"Content-Type\"\n        - \"User-Agent\"\n        - \"Accept\"\n        - \"/^X-Trace-.*$/\"\n      rules:\n        - host: \"api.openai.com\"\n```\n\n### Body capture\n\nRecords the decoded request body of matching requests and surfaces it on the\naudit log record in a `body_capture` group holding `request_body` and\n`request_body_truncated`. Useful for auditing the payloads passing through the\nproxy, such as the prompts a sandbox sends to an LLM provider, without\nmodifying the upstream traffic.\n\nHosts, methods, and paths are matched with the same `rules` syntax as\n`allowlist` and `secrets`. `max_request_body_bytes` caps how much of each body\nis captured; bodies larger than the cap are truncated to the prefix and\n`request_body_truncated` is set to `true`. The cap defaults to 16 KiB and is\nindependent of the global `proxy.max_request_body_bytes` limit. This transform\nis observation-only: it never rejects a request, and body read errors are\nannotated on the trace rather than failing the request.\n\nOn a successful capture, the transform's entry in `request_transforms` is\nannotated with `captured_bytes` and `truncated` so the trace records that a\nbody was captured without duplicating the body itself.\n\nResponse bodies are not captured. Streaming responses (SSE) would have to be\nbuffered end-to-end before forwarding, which would stall the client.\n\n\u003e **Warning:** Captured bodies are written to the audit log in plain text. When\n\u003e `secrets` runs with `match_body: true`, place `body_capture` *before* `secrets`\n\u003e so the audit log records the sandbox's proxy tokens rather than the real\n\u003e credentials `secrets` swaps into the body.\n\n```yaml\ntransforms:\n  - name: body_capture\n    config:\n      max_request_body_bytes: 16384\n      rules:\n        - host: \"api.anthropic.com\"\n          methods: [\"POST\"]\n          paths: [\"/v1/messages\"]\n        - host: \"api.openai.com\"\n          methods: [\"POST\"]\n          paths: [\"/v1/chat/completions\"]\n```\n\n### Secrets\n\nThe sandbox never holds real credentials. Instead:\n\n1. Configure iron-proxy with the real secret source: environment variables,\n   AWS Secrets Manager, AWS Systems Manager Parameter Store, 1Password (service\n   account), or 1Password Connect.\n2. Give the sandbox a proxy token (e.g., `proxy-openai-abc123`).\n3. Configure the `secrets` transform to map proxy tokens to those sources.\n\niron-proxy scans outbound requests and replaces proxy tokens with the real\nvalues before forwarding upstream. You control where it looks:\n\n- **`match_headers`:** list of header names to scan. Empty list = all headers.\n  Literal names are matched case-insensitively, but the casing you write is\n  preserved when the header is forwarded upstream. Entries delimited by `/.../`\n  are compiled as case-insensitive regular expressions matched against canonical\n  header names (e.g. `/^x-.*-key$/`).\n- **`match_body`:** scan the request body (buffered up to `max_request_body_bytes`).\n- **`match_query`:** scan the URL query string. Defaults to `false`; opt in for\n  upstreams that expect the secret in a query parameter. Query strings often\n  appear in access logs on either side of the proxy, so this is off by default.\n- **`match_path`:** scan the URL path. Defaults to `false`; opt in for upstreams\n  like Telegram that embed the secret in the path (e.g.\n  `/bot\u003cTOKEN\u003e/sendMessage`). URL paths often appear in access logs on either\n  side of the proxy, so this is off by default.\n- **`require`:** when `true`, requests to a matching host that do **not** contain\n  the proxy token are rejected with 403. This prevents a compromised workload\n  from bypassing the secret-swap mechanism with alternative credentials. Default: `false`.\n- **`hosts`:** restrict swapping to specific domains or CIDRs.\n\nQuery parameters are always scanned.\n\nSecret sources:\n\n- **`env`:** reads `var` from the proxy process environment.\n- **`aws_sm`:** reads `secret_id` from AWS Secrets Manager. Optional `region`,\n  `ttl`, and `failure_ttl` are supported.\n- **`aws_ssm`:** reads `name` from AWS Systems Manager Parameter Store. Optional\n  `region`, `with_decryption`, `ttl`, and `failure_ttl` are supported.\n  `with_decryption` defaults to `true`, which is the expected setting for\n  `SecureString` parameters.\n- **`1password`:** resolves `secret_ref` (an `op://vault/item/[section/]field`\n  reference) using a 1Password service account token. The token is read from\n  `OP_SERVICE_ACCOUNT_TOKEN` by default; override with `token_env`. Optional\n  `ttl` and `failure_ttl` are supported.\n- **`1password_connect`:** resolves the same `op://vault/item/[section/]field`\n  `secret_ref` against a self-hosted 1Password Connect server. The server URL\n  is read from `OP_CONNECT_HOST` and the API token from `OP_CONNECT_TOKEN` by\n  default; override with `host_env` and `token_env`. Optional `ttl` and\n  `failure_ttl` are supported.\n\nEvery source also accepts an optional `json_key`. When set, the resolved value\nis parsed as a JSON object and the single top-level string field at that key is\nextracted. Use it to pull one field out of a JSON secret.\n\n`ttl` controls how long a successfully fetched value is cached before refresh\n(empty caches forever). `failure_ttl` controls how long a fetch error is\ncached before retrying; it defaults to 1m and is independent of `ttl`, so a\nlong success TTL does not delay recovery from a transient backend outage.\n\n  \u003e **Note:** a bug in `onepassword-sdk-go` breaks builds with `CGO_ENABLED=0`,\n  \u003e so iron-proxy pins a [fork](https://github.com/ironsh/onepassword-sdk-go)\n  \u003e via a `replace` directive in `go.mod` until the fix lands upstream.\n\n### Judge\n\nThe judge transform calls an LLM to produce an allow/deny decision for\nrequests that match its URL rules. Each entry under `transforms:` is an\nindependent judge instance with its own natural-language policy, LLM backend,\ntimeout, semaphore, and circuit breaker. Operators can deploy zero, one, or\nmany judges with different prompts scoped to different rules.\n\n```yaml\n- name: judge\n  config:\n    name: \"github-write-guard\"    # required; identifies the instance in audit logs\n    fallback: \"deny\"              # deny (default) | skip. No \"allow\" fallback ships in v1.\n    timeout: \"8s\"                 # per-call LLM timeout\n    max_concurrent: 100           # semaphore capacity; additional calls wait\n    circuit_breaker:\n      consecutive_failures: 5\n      cooldown: \"10s\"\n    rules:                        # uses the same matcher as allowlist/secrets\n      - host: \"api.github.com\"\n        methods: [\"POST\", \"PATCH\", \"DELETE\", \"PUT\"]\n    provider:\n      type: \"anthropic\"           # \"anthropic\" or \"openai\"\n      model: \"claude-haiku-4-5-20251001\"\n      api_key_env: \"ANTHROPIC_API_KEY\"\n      max_tokens: 256\n    prompt: |\n      Natural-language policy describing what is allowed for requests that\n      match the rules above. Kept short and specific.\n```\n\nInvariants:\n\n- The judge can only reject. It never approves a request the static allowlist\n  would have denied. Static deny always wins.\n- Non-matching requests are ignored: no LLM call, no audit annotations.\n- On LLM error, timeout, circuit-breaker-open, or malformed model output, the\n  configured `fallback` applies. `deny` blocks the request (the recommended\n  default for production). `skip` defers to the rest of the pipeline; since\n  iron-proxy is default-deny, unmatched requests are still blocked.\n\nPipeline ordering with the secrets transform:\n\n- **Recommended:** place the judge **before** the secrets transform. The LLM\n  provider sees proxy tokens, never the real credentials the workload has\n  access to.\n- Alternatively, placing the judge after secrets lets it evaluate the exact\n  wire form that will egress, at the cost of sending real credentials to the\n  LLM provider. Only choose this if your threat model accepts that trade.\n\nSupported providers:\n\n- **`anthropic`** (Messages API). Uses `api_key_env`, `model`, optional\n  `base_url` and `max_tokens`.\n- **`openai`** (Chat Completions API). Same fields as above; set\n  `type: openai`, point `api_key_env` at the env var holding your OpenAI\n  key, and pick a model like `gpt-5.4-nano`.\n\nAudit output: every matched request adds structured fields under the transform\ntrace, including `judge.instance`, `judge.decision`, `judge.reason`,\n`judge.duration_ms`, `judge.input_tokens`, `judge.output_tokens`,\n`judge.fallback_applied` (when a fallback fires), and\n`judge.circuit_breaker_tripped` (when the breaker is open).\n\nCredits: thanks to Brex for their CrabTrap project (MIT-licensed), which\ninformed this design.\n\n## MCP policy\n\niron-proxy can speak [MCP's Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports). When a request matches a configured MCP server, the proxy parses the JSON-RPC body, applies a default-deny tool allowlist, and filters `tools/list` responses so denied tools never reach the agent. SSE responses are filtered per event so long-lived MCP streams stay live.\n\nThis is a first-class proxy capability rather than a transform: MCP responses can be open-ended SSE streams carrying arbitrary server-initiated messages, which does not fit the request/response transform contract.\n\n```yaml\nmcp:\n  # JSON-RPC error envelope returned to the agent on policy denial.\n  # Defaults: code -32001, message \"blocked by iron-proxy policy\".\n  error:\n    code: -32001\n    message: \"blocked by iron-proxy policy\"\n  servers:\n    - name: github                         # appears in audit as mcp.server\n      rules:                               # standard host/method/path rules\n        - host: \"mcp.github.com\"\n          paths: [\"/mcp\", \"/mcp/*\"]\n      tools:\n        - name: \"search_repositories\"      # always allowed\n        - name: \"create_issue\"\n          when:                            # all clauses must hold; otherwise deny\n            - path: \"owner\"                # dotted path against arguments\n              equals: \"ironsh\"\n            - path: \"repo\"\n              in: [\"iron-proxy\", \"tunis-v2\"]\n        # Anything not listed is denied (default-deny).\n```\n\nBehavior:\n\n- **`tools/call` enforcement.** Calls to tools that are not in the server's `tools` list, or whose `arguments` fail any `when` clause, are rejected without reaching upstream. The proxy returns a JSON-RPC error response with the configured code and message and the request's original `id`, so the MCP client sees a normal protocol error rather than an HTTP failure.\n- **`tools/list` filtering.** Responses to `tools/list` have any tool not on the allowlist removed before reaching the agent. Works for both `application/json` and `text/event-stream` responses; SSE filtering operates per event so heartbeats and other messages on the stream pass through untouched.\n- **Argument matching.** Each `when` clause has a dotted `path` (e.g. `arguments.repo`, `labels.0`) and one of `equals` (any JSON scalar), `in` (a list of scalars), or `matches` (a regex on string values). Clauses AND together. Omitting `when` allows the tool unconditionally.\n- **Audit.** Every observed JSON-RPC message is recorded under a new `mcp` section in the audit log entry: server name, direction (`request` or `response`), method, tool, decision (`allow`, `deny`, or `filtered`), reason on denials, and the count of tools removed on filter events.\n\nPipeline ordering: the MCP interceptor runs after the transform pipeline, so `allowlist` still gates which hosts can be reached and `secrets` has already swapped proxy tokens by the time the interceptor evaluates the body.\n\nLimitations in v1:\n\n- Only Streamable HTTP transport is supported. The legacy HTTP+SSE transport (separate `/messages` and `/sse` endpoints) is not.\n- A JSON-RPC batch with any denied entry is rejected as a whole batch; partial-batch forwarding is not supported.\n- Resources and prompts are not enforced. Agents can still call `resources/list`, `resources/read`, etc. without policy filtering.\n\n### Body limits\n\nTransforms that inspect or forward request/response bodies (secrets body\nmatching, gRPC transforms) operate on buffered bodies. Two global settings\ncontrol the maximum buffer sizes:\n\n- **`max_request_body_bytes`** (default: `1048576` / 1 MiB): caps how much of\n  the request body is buffered for transforms. Data beyond this limit is\n  truncated from the transform's perspective but still forwarded to upstream.\n- **`max_response_body_bytes`** (default: `0` / uncapped): caps how much of\n  the response body is buffered. Set to `0` to buffer the full response, which\n  is the right default for most workloads (e.g., npm packages, model weights).\n\nBodies are buffered incrementally as transforms read them, and automatically\nrewound between pipeline stages. If a transform doesn't read the body, no\nbuffering occurs and the body streams through untouched.\n\n### Tunnel listener (CONNECT/SOCKS5)\n\nThe tunnel listener accepts HTTP CONNECT and SOCKS5 connections on a\ndedicated port. This is useful for tools that natively support proxy\nconfiguration via `HTTPS_PROXY`/`ALL_PROXY` environment variables or\nSOCKS5 settings, rather than relying on DNS-based routing.\n\nTo enable it, set `tunnel_listen` under `proxy`:\n\n```yaml\nproxy:\n  tunnel_listen: \":8080\"\n```\n\nWhen omitted, the tunnel listener is disabled.\n\nBoth protocols go through the same transform pipeline as regular HTTP/HTTPS\nrequests. The proxy evaluates a synthetic CONNECT request against your\nallowlist and secrets transforms, so tunnel connections are subject to the\nsame default-deny policy.\n\nAfter the CONNECT or SOCKS5 handshake, the proxy peeks at the first byte to\ndetect the inner protocol:\n\n- **TLS (0x16):** performs MITM the same way as the HTTPS listener,\n  generating a leaf cert on the fly so transforms can inspect and rewrite\n  the request.\n- **Plain HTTP:** serves the request directly through the transform\n  pipeline.\n\n**HTTP CONNECT example:**\n\n```bash\ncurl -x http://172.20.0.2:8080 \\\n  --cacert /certs/ca.crt \\\n  https://httpbin.org/get\n```\n\n**SOCKS5 example:**\n\n```bash\ncurl --socks5-hostname 172.20.0.2:8080 \\\n  --cacert /certs/ca.crt \\\n  https://httpbin.org/get\n```\n\nYou can also set the standard environment variables so all tools route\nthrough the tunnel automatically:\n\n```bash\nexport HTTPS_PROXY=http://172.20.0.2:8080\nexport ALL_PROXY=socks5h://172.20.0.2:8080\n```\n\nThe SOCKS5 implementation supports no-auth only and accepts IPv4, IPv6,\nand domain name address types.\n\n### TLS\n\niron-proxy generates leaf certificates on the fly, signed by the CA you provide.\nThe client container must trust this CA (add it to the system trust store or pass\nit via `--cacert`). Certs are cached in an LRU cache keyed by SNI hostname.\n\n## Routing traffic to the proxy\n\nThere are three approaches, with increasing enforcement.\n\n### DNS-based (simple)\n\nPoint the container's DNS at iron-proxy. All lookups resolve to the proxy IP,\nso HTTP/HTTPS traffic flows through it naturally. This is what the\n[Docker Compose example](#docker-compose-example) uses:\n\n```yaml\nservices:\n  client:\n    dns:\n      - 172.20.0.2 # iron-proxy IP\n```\n\nEasy to set up but easy to bypass: the workload can hardcode IPs or use its\nown DNS resolver to skip the proxy entirely.\n\n### DNS + nftables egress firewall (enforced)\n\nLayer an nftables firewall on top of DNS routing. DNS still steers traffic to\nthe proxy, but nftables ensures the workload _can't_ talk to anything else,\neven with hardcoded IPs.\n\nThe [`examples/nftables`](examples/nftables/) directory has a working setup.\nThe client container loads firewall rules on startup before running any\napplication traffic:\n\n**nftables.conf** allows traffic to the proxy, drops everything else:\n\n```\ntable ip iron {\n  chain output {\n    type filter hook output priority 0; policy drop;\n\n    # allow loopback\n    oif lo accept\n\n    # allow traffic to the proxy itself (DNS + HTTP/HTTPS)\n    ip daddr 172.20.0.2 tcp dport { 80, 443 } accept\n    ip daddr 172.20.0.2 udp dport 53 accept\n\n    # allow established/related (return traffic)\n    ct state established,related accept\n\n    # log and drop everything else\n    log prefix \"iron-proxy-drop: \" drop\n  }\n}\n```\n\n**docker-compose.yml:** the client image is built with nftables\npre-installed. The entrypoint loads the rules, then runs the demo.\n`CAP_NET_ADMIN` is required to load the rules:\n\n```yaml\nservices:\n  proxy:\n    # ... same as DNS example ...\n    networks:\n      demo:\n        ipv4_address: 172.20.0.2\n\n  client:\n    build:\n      context: .\n      dockerfile: Dockerfile.client # alpine + curl + nftables\n    dns:\n      - 172.20.0.2\n    cap_add:\n      - NET_ADMIN\n    volumes:\n      - ./nftables.conf:/etc/nftables.conf:ro\n      - certs:/certs:ro\n    networks:\n      demo:\n        ipv4_address: 172.20.0.4\n```\n\nIn a production setup you'd load the rules in an entrypoint wrapper and then\n`exec` your actual process as a non-root user without `CAP_NET_ADMIN`.\n\n### TPROXY (transparent proxy)\n\nFor environments where you can't control the workload's DNS at all, nftables\nTPROXY can redirect traffic at the kernel level without any cooperation from\nthe workload. This intercepts packets in the PREROUTING chain and hands them\ndirectly to iron-proxy:\n\n```\ntable ip iron {\n  chain prerouting {\n    type filter hook prerouting priority mangle; policy accept;\n\n    # redirect HTTP/HTTPS to iron-proxy via TPROXY\n    tcp dport 80 tproxy to 172.20.0.2:80 meta mark set 1 accept\n    tcp dport 443 tproxy to 172.20.0.2:443 meta mark set 1 accept\n  }\n\n  chain output {\n    type route hook output priority mangle; policy accept;\n\n    # mark locally-originated packets for policy routing\n    tcp dport { 80, 443 } meta mark set 1\n  }\n}\n```\n\nThis requires `ip rule` and `ip route` setup to route marked packets to a\nlocal socket, plus iron-proxy must bind with `IP_TRANSPARENT`. This is more\ncomplex to set up but provides the strongest guarantee that traffic can't\nbypass the proxy. TPROXY operates below DNS, so it catches hardcoded IPs,\ncustom resolvers, and anything else the workload might try.\n\n## Docker Compose example\n\nThe [`examples/docker-compose`](examples/docker-compose/) directory contains a\nworking setup. The key pieces:\n\n**docker-compose.yml:** proxy and client on a shared bridge network. Real\nsecrets are set as env vars on the proxy container only:\n\n```yaml\nservices:\n  proxy:\n    build:\n      context: ../..\n      dockerfile: examples/docker-compose/Dockerfile\n    environment:\n      - OPENAI_API_KEY=sk-real-openai-key-do-not-share\n      - INTERNAL_TOKEN=real-internal-secret-value\n    volumes:\n      - certs:/certs\n    networks:\n      demo:\n        ipv4_address: 172.20.0.2\n\n  client:\n    image: alpine:latest\n    dns:\n      - 172.20.0.2 # Point DNS at the proxy\n    volumes:\n      - certs:/certs:ro\n    networks:\n      demo:\n        ipv4_address: 172.20.0.4\n```\n\n**proxy.yaml** allowlists `httpbin.org` and `icanhazip.com`, swaps two\nsecrets:\n\n```yaml\ntransforms:\n  - name: allowlist\n    config:\n      domains:\n        - \"httpbin.org\"\n        - \"icanhazip.com\"\n      cidrs:\n        - \"172.20.0.0/24\"\n\n  - name: secrets\n    config:\n      secrets:\n        - source:\n            type: env\n            var: OPENAI_API_KEY\n          replace:\n            proxy_value: \"proxy-openai-abc123\"\n            match_headers: [\"Authorization\"]\n            match_query: true # scan the query string\n          rules:\n            - host: \"httpbin.org\"\n\n        - source:\n            type: env\n            var: INTERNAL_TOKEN\n          proxy_value: \"proxy-internal-tok\"\n          match_headers: [] # scan all headers\n          rules:\n            - host: \"httpbin.org\"\n```\n\nThe client script sends five requests to demonstrate each behavior:\n\n```bash\n# 1. Allowed request\ncurl https://httpbin.org/get\n\n# 2. Blocked request (not in allowlist)\ncurl https://example.com/\n\n# 3. Secret swap: proxy token replaced with real key in Authorization header\ncurl -H \"Authorization: Bearer proxy-openai-abc123\" https://httpbin.org/headers\n\n# 4. Secret swap: proxy token in custom header\ncurl -H \"X-Internal: proxy-internal-tok\" https://httpbin.org/headers\n\n# 5. Secret swap: proxy token in query parameter\ncurl \"https://httpbin.org/get?token=proxy-openai-abc123\u0026q=hello\"\n```\n\n## Audit log format\n\nEvery proxied request produces a structured JSON log entry:\n\n```json\n{\n  \"host\": \"httpbin.org\",\n  \"method\": \"GET\",\n  \"path\": \"/headers\",\n  \"action\": \"allow\",\n  \"status_code\": 200,\n  \"duration_ms\": 142,\n  \"request_transforms\": [\n    {\n      \"name\": \"allowlist\",\n      \"action\": \"continue\"\n    },\n    {\n      \"name\": \"secrets\",\n      \"action\": \"continue\",\n      \"annotations\": {\n        \"swapped\": [{ \"secret\": \"OPENAI_API_KEY\", \"locations\": [\"header:Authorization\"] }]\n      }\n    }\n  ],\n  \"response_transforms\": []\n}\n```\n\nRejected requests include a `rejected_by` field and log at WARN level.\n\n## OpenTelemetry export\n\nAudit events can be exported as OpenTelemetry structured log records for\noffline analysis in backends like Axiom, ClickHouse, or Logfire. Set\n`OTEL_EXPORTER_OTLP_ENDPOINT` to enable:\n\n```bash\ndocker run -d --name iron-proxy \\\n  -e OTEL_EXPORTER_OTLP_ENDPOINT=https://logfire-us.pydantic.dev \\\n  -e OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \\\n  -e OTEL_EXPORTER_OTLP_HEADERS=\"Authorization=Bearer \u003ctoken\u003e\" \\\n  -e OTEL_SERVICE_NAME=iron-proxy \\\n  -e OTEL_RESOURCE_ATTRIBUTES=\"deployment.environment=staging\" \\\n  # ... other flags ...\n  ironsh/iron-proxy:latest -config /etc/iron-proxy/proxy.yaml\n```\n\nAll configuration uses standard OTEL environment variables:\n\n| Variable                       | Description                                             | Default          |\n| ------------------------------ | ------------------------------------------------------- | ---------------- |\n| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL. OTEL export is disabled when unset. | (disabled)       |\n| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` or `grpc`.                              | `http/protobuf`  |\n| `OTEL_EXPORTER_OTLP_HEADERS`  | Comma-separated `key=value` pairs for auth headers.     | (none)           |\n| `OTEL_SERVICE_NAME`            | Service name attached to all log records.                | `iron-proxy`     |\n| `OTEL_RESOURCE_ATTRIBUTES`    | Comma-separated `key=value` resource attributes.        | (none)           |\n\nWhen enabled, every audit event is emitted as an OTEL log record alongside the\nexisting JSON stderr logs. The log record carries the same schema as the JSON\naudit entry: `host`, `method`, `path`, `action`, `status_code`, `duration_ms`,\nand the full `request_transforms`/`response_transforms` arrays with annotations.\n\n## Management API\n\niron-proxy can optionally expose an authenticated HTTP API for operational\ntasks. Currently it serves a single endpoint, `POST /v1/reload`, which re-reads\nthe YAML config from disk and atomically swaps in a freshly built transform\npipeline. The running pipeline is preserved if the new config is invalid.\n\nThe management server is disabled by default. To enable, add a `management`\nblock to your config:\n\n```yaml\nmanagement:\n  # Bind on loopback unless you front this with a private network or auth proxy:\n  # /v1/reload can rebuild the entire transform pipeline.\n  listen: \"127.0.0.1:9092\"\n  # Env var that holds the bearer token. Defaults to IRON_MANAGEMENT_API_KEY.\n  api_key_env: \"IRON_MANAGEMENT_API_KEY\"\n```\n\nStandalone mode only — incompatible with control-plane managed mode.\n\nReload a running proxy:\n\n```bash\ncurl -X POST http://127.0.0.1:9092/v1/reload \\\n  -H \"Authorization: Bearer $IRON_MANAGEMENT_API_KEY\"\n```\n\n## iron.sh\n\nNeed Vault/KMS secret backends, a Kubernetes operator, or centralized policy\nmanagement? [iron.sh](https://iron.sh) builds on iron-proxy with enterprise\nfeatures for teams running this at scale.\n\n## Verify release signatures\n\nRelease artifacts include a signed checksum manifest:\n\n- `checksums.txt`\n- `checksums.txt.asc` (ASCII-armored detached signature)\n\nUse the included public key at [`public-key.asc`](public-key.asc) to verify:\n\n```bash\n# 1) Download release artifacts for a tag\nTAG=vX.Y.Z\ngh release download \"$TAG\" --pattern \"checksums.txt\" --pattern \"checksums.txt.asc\"\n\n# 2) Import the project signing key\ngpg --import public-key.asc\n\n# 3) Verify the signature over checksums.txt\ngpg --verify checksums.txt.asc checksums.txt\n```\n\nIf verification succeeds, GPG will report a good signature from `Matthew Slipper \u003cmatt@iron.sh\u003e`.\n\nYou can optionally inspect the imported key fingerprint and confirm it matches your trusted source before verification.\n\nTo verify a specific binary against the signed checksum list (example: `iron-proxy-linux-amd64`):\n\n```bash\nshasum -a 256 iron-proxy-linux-amd64 | grep -F \"$(grep -F 'iron-proxy-linux-amd64' checksums.txt | awk '{print $1}')\"\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fironsh%2Firon-proxy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fironsh%2Firon-proxy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fironsh%2Firon-proxy/lists"}