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