First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user