Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SideDoor
Finds services in Docker Compose files that are reachable without going through your reverse proxy.
What it does and why
Policy enforced at a reverse proxy (geo-blocking, authentication, rate limits, TLS) applies only to traffic that passes through the proxy. A container that publishes a host port sidesteps all of it. Nothing in the proxy's own configuration can tell you this happened, because the proxy does not know the door exists. You can read the whole proxy config and never learn that a sibling container opened port 2222 on the host.
So SideDoor looks at published host ports, not at the proxy config. You pass it parsed compose services, and it reports every binding that opens a path around the proxy:
| Binding | Result |
|---|---|
| A sensitive port published on the host (SSH, telnet, PostgreSQL, MySQL, Redis, MongoDB, Elasticsearch, AMQP, memcached, the Docker API) | Fail, critical |
| Any other published host port | Fail, high |
A port bound to loopback (127.0.0.1, localhost, ::1) |
Not applicable, still printed: not reachable from the internet, but any process on the host reaches it without the proxy |
| The proxy itself publishing 80 or 443 | Ignored. That is the edge doing its job. |
| No published ports | Counted as internal, the safe shape |
A traefik.enable=true label marks a service as proxied, but it does not
excuse a published port next to it. Being routable through the proxy says
nothing about the door beside it. The proxy service names (traefik,
proxy, caddy, nginx-proxy) and edge ports (80, 443) can be changed with
edgeServices and edgePorts.
Findings are sorted worst first, so a critical never hides under a page of info lines. An empty scan is reported as not assessed, never as clean. When there is nothing to report, the output says "No unproxied public bindings found" instead of leaving a blank, because a gap of this kind is invisible and a clean result has to be stated.
The library is pure: no Docker, no network and no filesystem. You parse the
compose files; it judges them. Reports use the @extant2000/evidence-record
format, and every finding cites the compose file, the line when your parser
tracked one, and the exact port mapping.
Usage
import { scan, toReport, formatScan } from '@extant2000/side-door'
const result = scan([
{ name: 'traefik', file: 'compose.yml', line: 3, ports: ['80:80', '443:443'] },
{ name: 'app', file: 'compose.yml', line: 12, labels: ['traefik.enable=true'] },
{ name: 'db', file: 'compose.yml', line: 20, ports: ['5432:5432'] },
{ name: 'cache', file: 'compose.yml', line: 28, ports: ['127.0.0.1:6379:6379'] },
{ name: 'worker', file: 'compose.yml', line: 34 },
])
const report = toReport(result) // evidence-record report
console.log(formatScan(result))
The finding lines from that output:
✗ CRITICAL db publishes 5432:5432 [NOT MET]
◦ info cache publishes 127.0.0.1:6379:6379 [N/A]
parseBinding() is exported too. It handles 80, 8080:80,
127.0.0.1:8080:80 and 8080:80/tcp, and returns null for anything it
cannot parse. A bare 80 still publishes a host port.
Install
npm install @extant2000/side-door
License
MIT. See LICENSE.