First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:55:53 -04:00
co-authored by Claude Opus 5.5
commit b1cdee4a85
13 changed files with 2124 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# 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
```ts
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](LICENSE).