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
+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)
}