Files
side-door/test/side-door.test.ts
2026-09-28 14:55:53 -04:00

144 lines
5.7 KiB
TypeScript

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('✓')
})
})