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
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env*
!.env.example
.npmrc
dist-cjs/
+6
View File
@@ -0,0 +1,6 @@
stages: [test]
test:
stage: test
image: node:22-alpine
script: [npm ci, npm run build, npm test]
+14
View File
@@ -0,0 +1,14 @@
# Changelog
## 0.2.0 - 2026-09-28
First public release under MIT.
- `scan()` finds compose services that publish host ports and so bypass the
reverse proxy. Sensitive ports are critical, other ports high, loopback
bindings informational.
- The proxy publishing 80 and 443 is not flagged. A proxy label does not
excuse a published port.
- `toReport()` and `formatScan()` produce and render a report in the
`@extant2000/evidence-record` format, citing file, line and binding.
- An empty scan is reported as not assessed, never as clean.
+22
View File
@@ -0,0 +1,22 @@
# Contributing
Issues and merge requests are welcome at
https://gitlab.com/extant2000/side-door.
## Ground rules
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
that fails without your fix, and check that it does fail before you submit.
- Measure, do not assume. Two modules with the same line count can be
different programs.
- Keep dependencies minimal. Every new dependency needs a reason.
- Explain why in comments, not what.
## Before you open a merge request
Run both of these and make sure they pass:
```
npm run build
npm test
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Extant 2000 LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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).
+6
View File
@@ -0,0 +1,6 @@
# Security
Please report vulnerabilities privately by email to security@extant2000.com.
Do not open a public issue for a security problem.
We aim to acknowledge every report within 5 business days.
+1504
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@extant2000/side-door",
"version": "0.2.0",
"private": false,
"description": "Finds services that can be reached without going through your reverse proxy.",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://gitlab.com/extant2000/side-door.git"
},
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"dependencies": {
"@extant2000/evidence-record": "^0.1.1"
},
"author": "Extant 2000 LLC",
"homepage": "https://gitlab.com/extant2000/side-door",
"bugs": {
"url": "https://gitlab.com/extant2000/side-door/-/issues"
}
}
+257
View File
@@ -0,0 +1,257 @@
/**
* SideDoor — find the services bypassing your proxy or mesh entirely.
*
* The problem: policy enforced at the reverse proxy (geo-gating,
* auth, rate limits, TLS) applies only to traffic that GOES THROUGH the proxy.
* A container publishing a host port sidesteps every one of those controls, and
* nothing in the proxy's own config can tell you it happened — the proxy has no
* idea the door exists.
*
* Pure: takes parsed compose services, returns findings. No Docker, no network,
* no filesystem, so it is testable and safe to run anywhere.
*/
import {
type CapabilityReport,
type Finding as EvidenceFinding,
type Severity,
evidence,
formatReport,
} from '@extant2000/evidence-record'
export interface ComposeService {
/** Service name as written in the compose file. */
name: string
/** Compose file this came from. */
file: string
/**
* 1-indexed line where this service is declared, when the caller's parser
* tracks it. Carried through to the citation so a finding opens in an
* editor rather than sending the reader hunting through the file.
*/
line?: number
/**
* Published host ports, e.g. "8080:80", "127.0.0.1:5432:5432", "2222:22".
* Only PUBLISHED ports matter — `expose` keeps a port on the container
* network, which is exactly the safe case.
*/
ports?: string[]
/** Raw label strings, e.g. "traefik.enable=true". */
labels?: string[]
/** `network_mode: service:gluetun` and similar. */
networkMode?: string
}
export type Exposure = 'public' | 'loopback' | 'proxied' | 'internal'
export interface Finding {
service: string
file: string
/** Line in `file` where the service is declared, when known. */
line?: number
/** The published port mapping that created the exposure. */
binding: string
hostPort: number
exposure: Exposure
/** Why this matters, in one line. */
reason: string
severity: 'critical' | 'high' | 'info'
}
export interface ScanReport {
findings: Finding[]
/** Services that route through the proxy as intended. */
proxied: string[]
/** Services with no published ports at all — the safe shape. */
internal: string[]
scanned: number
/**
* True when nothing was scanned. Never reported as "clean".
*
* @deprecated Read `overall(toReport(scan))` instead — it distinguishes
* "nothing was scanned" from "nothing applied" from a real pass, which a
* single boolean cannot. Kept because it is still accurate.
*/
vacuous: boolean
}
/** Ports whose exposure is nearly always a mistake outside a bastion. */
const SENSITIVE: Record<number, string> = {
22: 'SSH', 23: 'telnet', 3306: 'MySQL/MariaDB', 5432: 'PostgreSQL',
6379: 'Redis', 27017: 'MongoDB', 9200: 'Elasticsearch', 5672: 'AMQP',
11211: 'memcached', 2375: 'Docker API (unauthenticated)', 2376: 'Docker API',
}
/**
* Parse a compose port mapping.
*
* Forms: "80", "8080:80", "127.0.0.1:8080:80", "8080:80/tcp".
* A leading host IP of 127.0.0.1/localhost binds loopback only — reachable
* from the box but not the internet, which is a materially different risk.
*/
export function parseBinding(binding: string): { hostIp?: string, hostPort: number, containerPort: number } | null {
const clean = binding.split('/')[0]!.trim()
const parts = clean.split(':')
if (parts.length === 1) {
const p = Number(parts[0])
// A bare "80" publishes an EPHEMERAL host port — still published.
return Number.isFinite(p) ? { hostPort: p, containerPort: p } : null
}
if (parts.length === 2) {
const h = Number(parts[0]); const c = Number(parts[1])
return Number.isFinite(h) && Number.isFinite(c) ? { hostPort: h, containerPort: c } : null
}
if (parts.length >= 3) {
const ip = parts[0]
const h = Number(parts[parts.length - 2]); const c = Number(parts[parts.length - 1])
return Number.isFinite(h) && Number.isFinite(c) ? { hostIp: ip, hostPort: h, containerPort: c } : null
}
return null
}
function isLoopback(ip?: string): boolean {
return ip === '127.0.0.1' || ip === 'localhost' || ip === '::1'
}
function proxyEnabled(labels?: string[]): boolean {
return (labels ?? []).some(l => /traefik\.enable\s*=\s*true/i.test(l))
}
export interface ScanOptions {
/**
* Ports the proxy itself legitimately owns (80/443 by default). A service
* publishing these IS the edge, not a bypass of it.
*/
edgePorts?: number[]
/** Service names known to be the proxy. */
edgeServices?: string[]
}
/**
* Find services reachable without traversing the proxy.
*
* The check is deliberately about PUBLISHED HOST PORTS rather than about the
* proxy's config, because that is the asymmetry that makes this class of gap
* invisible: you can read the entire Traefik configuration and never learn that
* a sibling container opened port 2222 on the host.
*/
export function scan(services: ComposeService[], opts: ScanOptions = {}): ScanReport {
const edgePorts = new Set(opts.edgePorts ?? [80, 443])
const edgeServices = new Set(opts.edgeServices ?? ['traefik', 'proxy', 'caddy', 'nginx-proxy'])
const findings: Finding[] = []
const proxied: string[] = []
const internal: string[] = []
for (const svc of services) {
const published = svc.ports ?? []
if (published.length === 0) {
// No host port: reachable only on the container network. The safe shape.
if (proxyEnabled(svc.labels)) proxied.push(svc.name)
else internal.push(svc.name)
continue
}
for (const binding of published) {
const parsed = parseBinding(binding)
if (!parsed) continue
// The proxy publishing 80/443 is the edge doing its job.
if (edgeServices.has(svc.name) && edgePorts.has(parsed.hostPort)) continue
const sensitive = SENSITIVE[parsed.containerPort] ?? SENSITIVE[parsed.hostPort]
const loop = isLoopback(parsed.hostIp)
if (loop) {
findings.push({
service: svc.name, file: svc.file, line: svc.line, binding, hostPort: parsed.hostPort,
exposure: 'loopback',
reason: sensitive
? `${sensitive} bound to loopback — not internet-reachable, but any process on the host reaches it unproxied`
: 'Published to loopback only — bypasses the proxy for on-host callers',
severity: 'info',
})
continue
}
findings.push({
service: svc.name, file: svc.file, line: svc.line, binding, hostPort: parsed.hostPort,
exposure: 'public',
reason: sensitive
? `${sensitive} published on a host port — reachable WITHOUT the proxy, so geo-gating, auth and rate limits do not apply`
: 'Published on a host port — reachable without traversing the proxy',
severity: sensitive ? 'critical' : 'high',
})
}
if (proxyEnabled(svc.labels)) proxied.push(svc.name)
}
// Worst first, so a critical never hides under a page of info lines.
const rank = { critical: 0, high: 1, info: 2 } as const
findings.sort((a, b) => rank[a.severity] - rank[b.severity] || a.service.localeCompare(b.service))
return {
findings,
proxied,
internal,
scanned: services.length,
// Nothing scanned is not a clean bill of health.
vacuous: services.length === 0,
}
}
/**
* Convert a scan into the portfolio-standard report shape.
*
* Every finding carries the compose file (and line, when the caller's parser
* tracked one) plus the exact port mapping as the excerpt — so a reader can
* open the door being described rather than take its existence on trust.
*
* Loopback bindings map to `not-applicable`, not `pass`: the criterion is
* "reachable without traversing the proxy", and a loopback bind genuinely
* sits outside it while still being worth printing.
*/
export function toReport(r: ScanReport): CapabilityReport {
const findings: EvidenceFinding[] = r.findings.map((f, i) => ({
id: `sd-${i}`,
summary: `${f.service} publishes ${f.binding}`,
determination: f.exposure === 'loopback' ? 'not-applicable' : 'fail',
severity: f.severity as Severity,
detail: f.reason,
evidence: [evidence.file(f.file, f.line, f.binding, `service "${f.service}"`)],
}))
return {
capability: 'SideDoor',
scope: `${r.scanned} compose service${r.scanned === 1 ? '' : 's'}`,
examined: r.scanned,
findings,
notes: r.scanned === 0 ? [] : notesFor(r),
}
}
function notesFor(r: ScanReport): string[] {
const unproxied = r.findings.filter(f => f.exposure !== 'loopback').length
const notes = [
`proxied: ${r.proxied.length} internal: ${r.internal.length} unproxied bindings: ${unproxied}`,
]
// Said explicitly rather than inferred from an absence of lines. The
// whole point of this capability is that a gap here is invisible, so its
// clean result has to be stated, not left as white space.
if (unproxied === 0) notes.push('No unproxied public bindings found.')
return notes
}
/**
* Render a scan for a terminal or CI log.
*
* Delegates to the shared evidence-record renderer, so SideDoor reads like
* every other library built on it. An empty scan reports NOT ASSESSED with the sentence
* explaining why that is not a pass — it used to say VACUOUS, which was
* precise and required a glossary.
*/
export function formatScan(r: ScanReport, opts: Parameters<typeof formatReport>[1] = {}): string {
return formatReport(toReport(r), opts)
}
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest'
import { overall, validateReport } from '@extant2000/evidence-record'
import { formatScan, parseBinding, scan, toReport, type ComposeService } from '../src/index.js'
const svc = (name: string, ports?: string[], labels?: string[]): ComposeService =>
({ name, file: 'docker-compose.yaml', ports, labels })
describe('parseBinding', () => {
it('parses every compose port form', () => {
expect(parseBinding('8080:80')).toEqual({ hostPort: 8080, containerPort: 80 })
expect(parseBinding('127.0.0.1:5432:5432')).toEqual({ hostIp: '127.0.0.1', hostPort: 5432, containerPort: 5432 })
expect(parseBinding('8080:80/tcp')).toEqual({ hostPort: 8080, containerPort: 80 })
})
it('treats a bare port as published — it still opens a host port', () => {
expect(parseBinding('80')).toEqual({ hostPort: 80, containerPort: 80 })
})
it('returns null on junk rather than guessing', () => {
expect(parseBinding('not-a-port')).toBeNull()
})
})
describe('scan — the asymmetry this exists for', () => {
it('flags a sensitive service published on a host port as CRITICAL', () => {
// You can read the whole proxy config and never learn this door exists.
const r = scan([svc('db', ['5432:5432'])])
expect(r.findings[0]!.severity).toBe('critical')
expect(r.findings[0]!.reason).toContain('PostgreSQL')
expect(r.findings[0]!.reason).toContain('geo-gating, auth and rate limits do not apply')
})
it('flags SSH and the Docker API', () => {
expect(scan([svc('a', ['2222:22'])]).findings[0]!.reason).toContain('SSH')
expect(scan([svc('b', ['2375:2375'])]).findings[0]!.reason).toContain('Docker API')
})
it('rates a non-sensitive published port HIGH, not critical', () => {
expect(scan([svc('app', ['8080:80'])]).findings[0]!.severity).toBe('high')
})
it('downgrades loopback to info — real, but not internet-reachable', () => {
const f = scan([svc('db', ['127.0.0.1:5432:5432'])]).findings[0]!
expect(f.severity).toBe('info')
expect(f.exposure).toBe('loopback')
expect(f.reason).toContain('any process on the host reaches it unproxied')
})
it('does NOT flag the proxy publishing 80/443 — that IS the edge', () => {
const r = scan([svc('traefik', ['80:80', '443:443'])])
expect(r.findings).toHaveLength(0)
})
it('still flags the proxy publishing something else', () => {
expect(scan([svc('traefik', ['8080:8080'])]).findings).toHaveLength(1)
})
it('counts a service with no published ports as internal — the safe shape', () => {
const r = scan([svc('worker')])
expect(r.internal).toEqual(['worker'])
expect(r.findings).toHaveLength(0)
})
it('counts a labelled, unpublished service as proxied', () => {
const r = scan([svc('web', [], ['traefik.enable=true'])])
expect(r.proxied).toEqual(['web'])
})
it('a traefik label does NOT excuse a published port', () => {
// Being routable through the proxy says nothing about the door beside it.
const r = scan([svc('web', ['9000:9000'], ['traefik.enable=true'])])
expect(r.findings).toHaveLength(1)
expect(r.proxied).toContain('web')
})
it('sorts worst-first so a critical never hides under info lines', () => {
const r = scan([
svc('a', ['127.0.0.1:8096:8096']),
svc('b', ['8080:80']),
svc('c', ['5432:5432']),
])
expect(r.findings.map(f => f.severity)).toEqual(['critical', 'high', 'info'])
})
it('reports an empty scan as NOT ASSESSED, never clean', () => {
const r = scan([])
expect(overall(toReport(r))).toBe('not-assessed')
const out = formatScan(r)
expect(out).toContain('NOT ASSESSED')
expect(out).toContain('not a pass')
expect(out).not.toContain('✓')
// The word it replaced needed a glossary.
expect(out).not.toContain('VACUOUS')
})
it('says so plainly when there is genuinely nothing public', () => {
const out = formatScan(scan([svc('traefik', ['443:443']), svc('worker')]))
expect(out).toContain('No unproxied public bindings found')
})
})
describe('conformance with the evidence-record standard', () => {
const real = scan([
svc('db', ['5432:5432']),
svc('cache', ['127.0.0.1:6379:6379']),
svc('traefik', ['80:80', '443:443']),
svc('worker'),
])
it('every conclusion carries a citation', () => {
expect(validateReport(toReport(real))).toEqual([])
})
it('names the file and the exact binding, not just the service', () => {
const out = formatScan(real, { evidence: 'full' })
expect(out).toContain('docker-compose.yaml')
expect(out).toContain('5432:5432')
expect(out).toContain('service "db"')
})
it('carries a line number into the citation when the parser tracked one', () => {
const withLine: ComposeService = { name: 'db', file: 'compose.yml', line: 42, ports: ['5432:5432'] }
expect(formatScan(scan([withLine]))).toContain('compose.yml:42')
})
it('treats a loopback bind as not-applicable, not as a pass', () => {
// It is outside the criterion ("reachable without traversing the proxy")
// but still worth printing, and it is not evidence that anything passed.
const r = toReport(scan([svc('cache', ['127.0.0.1:6379:6379'])]))
expect(r.findings[0]!.determination).toBe('not-applicable')
expect(overall(r)).toBe('not-applicable')
})
it('a real bypass rolls the whole report up to a failure', () => {
expect(overall(toReport(real))).toBe('fail')
})
it('a genuinely clean scan is a pass, and says which criterion was met', () => {
const r = toReport(scan([svc('traefik', ['443:443']), svc('worker')]))
expect(overall(r)).toBe('pass')
expect(formatScan(scan([svc('traefik', ['443:443']), svc('worker')]))).toContain('✓')
})
})
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
"declaration": true, "outDir": "dist", "rootDir": "src",
"strict": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}