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