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