First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `scan()` finds compose services that publish host ports and so bypass the
|
||||||
|
reverse proxy. Sensitive ports are critical, other ports high, loopback
|
||||||
|
bindings informational.
|
||||||
|
- The proxy publishing 80 and 443 is not flagged. A proxy label does not
|
||||||
|
excuse a published port.
|
||||||
|
- `toReport()` and `formatScan()` produce and render a report in the
|
||||||
|
`@extant2000/evidence-record` format, citing file, line and binding.
|
||||||
|
- An empty scan is reported as not assessed, never as clean.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/side-door.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
|
||||||
|
that fails without your fix, and check that it does fail before you submit.
|
||||||
|
- Measure, do not assume. Two modules with the same line count can be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new dependency needs a reason.
|
||||||
|
- Explain why in comments, not what.
|
||||||
|
|
||||||
|
## Before you open a merge request
|
||||||
|
|
||||||
|
Run both of these and make sure they pass:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Extant 2000 LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# SideDoor
|
||||||
|
|
||||||
|
Finds services in Docker Compose files that are reachable without going
|
||||||
|
through your reverse proxy.
|
||||||
|
|
||||||
|
## What it does and why
|
||||||
|
|
||||||
|
Policy enforced at a reverse proxy (geo-blocking, authentication, rate
|
||||||
|
limits, TLS) applies only to traffic that passes through the proxy. A
|
||||||
|
container that publishes a host port sidesteps all of it. Nothing in the
|
||||||
|
proxy's own configuration can tell you this happened, because the proxy does
|
||||||
|
not know the door exists. You can read the whole proxy config and never learn
|
||||||
|
that a sibling container opened port 2222 on the host.
|
||||||
|
|
||||||
|
**So SideDoor looks at published host ports, not at the proxy config.** You
|
||||||
|
pass it parsed compose services, and it reports every binding that opens a
|
||||||
|
path around the proxy:
|
||||||
|
|
||||||
|
| Binding | Result |
|
||||||
|
|---|---|
|
||||||
|
| A sensitive port published on the host (SSH, telnet, PostgreSQL, MySQL, Redis, MongoDB, Elasticsearch, AMQP, memcached, the Docker API) | Fail, critical |
|
||||||
|
| Any other published host port | Fail, high |
|
||||||
|
| A port bound to loopback (`127.0.0.1`, `localhost`, `::1`) | Not applicable, still printed: not reachable from the internet, but any process on the host reaches it without the proxy |
|
||||||
|
| The proxy itself publishing 80 or 443 | Ignored. That is the edge doing its job. |
|
||||||
|
| No published ports | Counted as internal, the safe shape |
|
||||||
|
|
||||||
|
A `traefik.enable=true` label marks a service as proxied, but it does not
|
||||||
|
excuse a published port next to it. Being routable through the proxy says
|
||||||
|
nothing about the door beside it. The proxy service names (`traefik`,
|
||||||
|
`proxy`, `caddy`, `nginx-proxy`) and edge ports (80, 443) can be changed with
|
||||||
|
`edgeServices` and `edgePorts`.
|
||||||
|
|
||||||
|
Findings are sorted worst first, so a critical never hides under a page of
|
||||||
|
info lines. An empty scan is reported as not assessed, never as clean. When
|
||||||
|
there is nothing to report, the output says "No unproxied public bindings
|
||||||
|
found" instead of leaving a blank, because a gap of this kind is invisible
|
||||||
|
and a clean result has to be stated.
|
||||||
|
|
||||||
|
The library is pure: no Docker, no network and no filesystem. You parse the
|
||||||
|
compose files; it judges them. Reports use the `@extant2000/evidence-record`
|
||||||
|
format, and every finding cites the compose file, the line when your parser
|
||||||
|
tracked one, and the exact port mapping.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { scan, toReport, formatScan } from '@extant2000/side-door'
|
||||||
|
|
||||||
|
const result = scan([
|
||||||
|
{ name: 'traefik', file: 'compose.yml', line: 3, ports: ['80:80', '443:443'] },
|
||||||
|
{ name: 'app', file: 'compose.yml', line: 12, labels: ['traefik.enable=true'] },
|
||||||
|
{ name: 'db', file: 'compose.yml', line: 20, ports: ['5432:5432'] },
|
||||||
|
{ name: 'cache', file: 'compose.yml', line: 28, ports: ['127.0.0.1:6379:6379'] },
|
||||||
|
{ name: 'worker', file: 'compose.yml', line: 34 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const report = toReport(result) // evidence-record report
|
||||||
|
console.log(formatScan(result))
|
||||||
|
```
|
||||||
|
|
||||||
|
The finding lines from that output:
|
||||||
|
|
||||||
|
```
|
||||||
|
✗ CRITICAL db publishes 5432:5432 [NOT MET]
|
||||||
|
◦ info cache publishes 127.0.0.1:6379:6379 [N/A]
|
||||||
|
```
|
||||||
|
|
||||||
|
`parseBinding()` is exported too. It handles `80`, `8080:80`,
|
||||||
|
`127.0.0.1:8080:80` and `8080:80/tcp`, and returns `null` for anything it
|
||||||
|
cannot parse. A bare `80` still publishes a host port.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/side-door
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue for a security problem.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/side-door",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Finds services that can be reached without going through your reverse proxy.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitlab.com/extant2000/side-door.git"
|
||||||
|
},
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.1"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/side-door",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/side-door/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
@@ -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('✓')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||||
|
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||||
|
"strict": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user