| name | network-exposure-baseline |
| description | Allowlist/blocklist for network bind targets (compose ports, redis bind, postgres listen_addresses, systemd ListenStream); load before any port change. |
Network Exposure Baseline
When To Use
Load THIS skill before any change that touches: docker-compose ports / expose; redis.conf bind / protected-mode; postgresql.conf listen_addresses; systemd .socket ListenStream; a bare network listener (a runtime bind argument such as host=0.0.0.0); firewall / UFW rules.
NOT for: purely internal refactoring with no networking change; purely application-logic work.
Why It Matters (founding principle)
Public-by-default = breach-by-default.
Background: in a prior incident in the ecosystem, Redis 7.x listened on 0.0.0.0:6379 without auth and Postgres ran with listen_addresses='*' and the default password — both were reachable from the public internet, which led to an abuse report from the regulator. Root cause: docker compose default-binds to 0.0.0.0 and there was no CI/CD gate.
The goal of this baseline: make a restricted bind the default; public exposure is opt-in with a justification and a TTL.
Tier Model (canonical)
| Tier | Bind targets | Justification | Example |
|---|
| Tier 0 | unix socket / no port published | not required | /run/redis/redis.sock |
| Tier 1 | 127.0.0.1, ::1, ::ffff:127.0.0.1 | not required | 127.0.0.1:5432 |
| Tier 2 | Tailscale CGNAT 100.64.0.0/10 + ::ffff:100.64.0.0/10 | not required (mesh-only by definition) | 100.65.1.5:5432 |
| Tier 3 | 0.0.0.0, ::, public IPs, mapped public IPv6, *, listen_addresses='*', any specific public IPv4, RFC1918 private (10/8, 172.16/12, 192.168/16), link-local (169.254/16) | REQUIRED — x-exposure-justification + x-exposure-expires (≤90 days) | 203.0.113.10:443, 192.168.1.50:5432, 0.0.0.0:443 + Cloudflare ACL |
Why specific public AND private IPv4 are both Tier 3. A static linter cannot read the host routing table, so it cannot prove that a private RFC1918 (or link-local) bind is mesh-only the way a loopback bind is safe-by-construction — a private address bridged onto a public-routed interface is a real exposure. Block-by-default folds all of them into Tier 3: the justification forces a human to record why the bind is safe, and the TTL forces re-review. Only loopback and Tailscale CGNAT are pass-by-construction. A genuinely unparseable bind string (not a dotted-quad, not a recognised IPv6) remains malformed (exit 2) — distinct from a Tier-3 violation (exit 1), which is a valid bind missing its annotation.
Decision Tree
Walk this tree for every bind target in the diff.
flowchart TD
A([Start]) --> B{Port published?}
B -->|No| C[Tier 0 PASS]
B -->|Yes| D{Host IP extractable<br/>from bind string?}
D -->|No / short-form| E[Implicit 0.0.0.0<br/>Tier 3 without justification]
E --> F[FAIL]
D -->|Yes| G{Classify IP}
G -->|127.0.0.1 / ::1 /<br/>::ffff:127.0.0.1| H[Tier 1 PASS]
G -->|100.64.0.0/10 /<br/>::ffff:100.64.0.0/10| I[Tier 2 PASS]
G -->|0.0.0.0 / :: / [::] /<br/>* / listen_addresses=*| J[Tier 3]
G -->|Specific public IPv4| J
G -->|RFC1918 private /<br/>169.254 link-local| J
G -->|Other mapped IPv6| J
G -->|Other IPv6| J
G -->|Not an IP at all| F
J --> K{Justification + TTL valid?<br/>expires ≤90d, in future}
K -->|Yes| L[Tier 3 PASS]
K -->|No| F
Allowlist (verbatim)
These targets are allowed with no extra justification.
127.0.0.1
::1
::ffff:127.0.0.1
100.64.0.0/10 (Tailscale CGNAT)
::ffff:100.64.0.0/10 (mapped Tailscale)
- unix socket paths (anything starting with
/, ending with .sock)
Blocklist (default deny)
Anything not in the allowlist and without a valid justification is denied by default.
0.0.0.0
:: / [::] / 0:0:0:0:0:0:0:0
*
listen_addresses = '*'
bind 0.0.0.0
ListenStream=0.0.0.0 / [::]
- short-form ports (for example
"5432:5432", "6379") — implicit 0.0.0.0
- Dockerfile
EXPOSE without host context — emit warn, do not fail by default
Justification format
Tier 3 requires an explicit exposure justification.
Primary — YAML extension (docker-compose)
services:
api:
ports:
- "0.0.0.0:443:443"
x-exposure-justification: "Public HTTPS endpoint behind edge ACL + rate-limit"
x-exposure-expires: "YYYY-MM-DD"
Fallback — inline comment (non-compose)
# exposure: justified expires=YYYY-MM-DD — short reason
bind 100.64.1.5
Authorship-time rule
When writing any wildcard bind (0.0.0.0:PORT, [::]:PORT) into a config file, env template, or systemd/launchd unit, the inline # exposure: justification annotation MUST be added in the SAME edit that introduces the bind. Do not defer the annotation to a review or compliance stage — it is zero-logic, zero-risk, and always knowable at authorship time; deferring it just converts a free fix into a review finding.
TTL rule
- Mandatory for Tier 3.
- The
expires date MUST be in the future and ≤90 days from the file's last modification date.
- Missing or expired (when required) → FAIL.
- Rationale: waivers accumulate and are forgotten; the TTL forces a quarterly review.
Compose variable interpolation (${VAR} in a port string)
A docker-compose port string is often a shell-style parameter expansion — e.g. a mesh bind ${TAILNET_IP:?tailnet ip required}:443:443, or a dev-compose published port ${PORT:-3700}:3700. The linter reads the string verbatim (it never shell-expands it) and resolves it with the B3 hybrid strategy, in order:
- env-resolve — if the named environment variable is set, its value is classified. (Resolution is
printenv-only; the value is never executed.)
- default-extract — for the
${VAR:-D} / ${VAR-D} forms with the variable unset, the default literal D is classified. So ${BIND_HOST:-0.0.0.0} with no env is a Tier-3 violation, and ${BIND_HOST:-127.0.0.1} passes.
- unresolved residual — for
${VAR:?msg} or bare ${VAR} in the host slot with the variable unset and no default, the linter first checks the variable name (see mesh var-name recognition below); if the name is not a tailnet name it emits a WARN naming the variable + file:line and PASSES (exit 0). It cannot know the runtime value in CI where the variable is intentionally unset, and false-failing every legitimate mesh bind would make the linter a CI nuisance.
Interpolation applies to every port segment, not only the host. Steps 1–2 resolve a ${VAR:-D} / ${VAR} in the published-port or target-port slot too: 127.0.0.1:${PORT:-3700}:3700 resolves to 127.0.0.1:3700:3700 (Tier 1 pass), and the short-form ${PORT:-3700}:3700 resolves to 3700:3700 — a genuine implicit-0.0.0.0 short-form Tier-3 violation (no longer an "unrecognized port form"). The modifier colon of ${VAR:-D} / ${VAR:?msg} is not a segment separator; only the : between host / published / target ports splits segments.
Tailscale-mesh var-name recognition. When an unresolved ${VAR} / ${VAR:?} sits in the host slot and its name matches the tailnet allowlist — case-insensitive (TAILSCALE|TAILNET|TSNET|MESH)_?(IP|ADDR|HOST|BIND), e.g. ${TAILSCALE_IP}, ${TAILNET_IP} — the linter classifies it Tier 2 (Tailscale-bound) and PASSES with no WARN, instead of the anonymous residual WARN. The Observability Stack compose uses six ${TAILSCALE_IP}:PORT:PORT binds; treating them as mesh-bound by name removes the CI noise while keeping intent greppable. Name recognition is the unset fallback only — if the variable IS set in the environment, its resolved value is classified normally (a tailnet-named var pointing at a public IP still fails Tier 3).
Recommendation: for a required mesh variable use the ${VAR:?reason} form — it both documents intent and forces compose itself to fail fast if the variable is missing at deploy time. Name it after the tailnet interface (TAILSCALE_IP / TAILNET_IP) to get the Tier-2 var-name pass. Avoid ${VAR:-default} where the default would classify into a different (weaker) tier than the intended runtime value.
Residual accepted risk. An unresolved ${UNKNOWN_PUBLIC_IP} passes with a WARN even if its runtime value would be a public bind. The residual is deliberate, greppable, and reviewer-visible: every such WARN names the variable and file:line, so a reviewer scanning the linter output sees exactly which binds were accepted unresolved. Resolve the variable in the environment (or pin it via :-default) to get a hard classification.
Security boundary. The ${...} body is untrusted compose input. The variable name is regex-validated (^[A-Za-z_][A-Za-z0-9_]*$) as the trust boundary before any environment read; resolution is printenv-only — no eval, no source, no indirect expansion, no shell expansion. A token whose body carries command-substitution or shell metacharacters ($(...), backtick, ;, |, &, <, >) is rejected outright as an injection attempt (violation, exit 1) and is never resolved.
Examples Gallery
8 side-by-side cases (compose syntax)
- PASS:
127.0.0.1:5432:5432 (Tier 1)
- PASS:
100.65.1.5:5432:5432 (Tier 2 Tailscale)
- PASS:
[::1]:5432:5432 (Tier 1 IPv6)
- PASS:
0.0.0.0:443:443 + valid justification + expires (Tier 3 justified)
- FAIL:
0.0.0.0:6379:6379 — no justification (Tier 3 unjustified)
- FAIL:
5432:5432 — short-form (implicit Tier 3)
- FAIL:
0.0.0.0:443:443 + expires=2025-01-01 (expired)
- FAIL:
[::]:5432:5432 (IPv6 unspecified)
Anti-patterns
Common mistakes that trigger FAIL
- Short-form ports in
docker-compose without a host-IP.
- Justification text without a description of the actual mitigation (for example just "because we have to").
- Pushing
expires far into the future (>90 days) to bypass review.
listen_addresses='localhost,*' (mixed allow + deny).
bind 0.0.0.0 in production Redis under the excuse "it's inside Docker network".
- Allow only Cloudflare IPs but without confirming that the origin is unreachable directly.
Verifier Integration
Verification program: "${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-check.sh" parses docker-compose.yml / redis.conf / postgresql.conf / systemd .socket; reads x-exposure-justification + x-exposure-expires; applies the classification from this skill. Drift between the skill and the script is a defect — update both at the same time.
Invocation:
"${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-check.sh" --compose path/to/docker-compose.yml
Exit codes:
0 — clean
1 — violation
2 — usage error
Pipeline Integration (forward reference)
This skill is consumed by:
/dr-prd — a Network Exposure Baseline section is mandatory in the PRD (Tier declaration plus justifications for Tier 3).
/dr-plan — warning if the plan touches networking surfaces without an explicit Tier classification.
/dr-do — pre-commit-style check on the diff: new 0.0.0.0, short-form ports, no # exposure: justified → block.
/dr-archive — validation-checklist gate: every Tier 3 bind has an unexpired justification.
Tiered Gate Rules (canonical decision table)
Pipeline commands read the task-description frontmatter and pick one of three decisions: hard_block (the gate blocks the step), advisory_warn (the gate prints a warning but does not block), skip (the gate is silent). Decision table:
| Priority | Type | Network surface touched? | Decision |
|---|
P0 | (any) | (any) | hard_block |
P1 | security-incident / infrastructure / infra / framework-hardening / security-baseline / auth-mandate / security | (any) | hard_block |
P1 | others | (any) | advisory_warn |
P2/P3/P4 | (any) | yes | advisory_warn |
P2/P3/P4 | (any) | no | skip |
init-task artefact (artifact: init-task, no priority/type) | — | no | skip |
init-task artefact (artifact: init-task, no priority/type) | — | yes | advisory_warn |
| missing/malformed (non-init-task) | — | — | hard_block (fail-closed) |
"Network surface touched" means that the diff (for /dr-plan) or the staged change (for /dr-do) touches one of the verifier's sources: docker-compose, redis.conf, postgresql.conf, systemd .socket, firewall / UFW rules, or a runtime bind argument.
An init-task artefact uses a different frontmatter schema (no priority/type by design). Early pipeline stages (/dr-prd, /dr-plan) may probe the init-task before a task-description exists; the gate resolves to skip (no networking surface to gate) rather than fail-closing, unless an explicit network-diff signal is present. The fail-closed hard_block is reserved for a genuinely malformed task-description (a file that is not an init-task yet lacks a valid priority).
The canonical executor is "${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-gate.sh". Drift between this skill and the script is a defect — update both at the same time. Every gate decision is reported as telemetry to Ops Bot (category: info, agent: dr-prd|dr-plan|dr-do|dr-archive, body: gate=<decision> task=<id>) for quarterly tuning via /dr-optimize.
Example invocation from a pipeline command:
decision=$("${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-gate.sh" \
--task-description datarim/tasks/<TASK-ID>-task-description.md \
--network-diff \
--quiet)
case "$decision" in
hard_block)
;;
advisory_warn)
;;
skip)
;;
esac
Machine-parseable Rules Block
allowlist:
- 127.0.0.1
- ::1
- ::ffff:127.0.0.1
- 100.64.0.0/10
- ::ffff:100.64.0.0/10
blocklist:
- 0.0.0.0
- '::'
- '*'
- listen_addresses='*'
- bind 0.0.0.0
- 'ListenStream=0.0.0.0'
short_form_ports: deny
unix_socket: allow
ttl_max_days: 90
justification_required_tiers: [3]
expired_justification: deny
Consumer Wiring Example
A CI job that consumes this baseline is a normal job in the pipeline — it
does not need to run last. Model the dependency explicitly with an array
needs, not a forced sequential chain:
jobs:
network-exposure-baseline:
needs: [lint, unit-tests]
steps:
- run: "${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-check.sh" --compose docker-compose.yml
needs accepts either a scalar (needs: lint) or an array (needs: [lint, unit-tests]); prefer the array form as soon as a second real dependency
exists, so additional prior jobs can be listed without a syntax rewrite.
Order jobs by their actual dependency graph, not by convenience: lint
and unit-tests above have no dependency on each other and should run in
parallel (both listed in needs, not chained lint → unit-tests → network-exposure-baseline); only declare a needs edge where one job's
output is genuinely required as the other's input. A forced sequential
chain between independent jobs adds wall-clock time with no correctness
benefit.
Local Pre-PR Smoke Recipe
Run both cases locally before opening a PR that touches a network bind —
catching a Tier-3 regression here is cheaper than waiting for CI.
Positive case (current/live setup)
Run the verifier against the live compose file as-is; it should PASS (or
fail only on pre-existing, already-justified findings):
"${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-check.sh" --compose docker-compose.yml
echo "exit=$?"
Negative case (synthetic Tier-3 copy — prove the gate actually fires)
Copy the compose file to a scratch location, break one bind on purpose, and
confirm the check rejects it — this proves the gate is wired and not a
silent no-op:
TASK_ID="TUNE-XXXX"
NEG_DIR="/tmp/${TASK_ID}-negsmoke"
rm -rf "$NEG_DIR" && mkdir -p "$NEG_DIR"
cp docker-compose.yml "$NEG_DIR/docker-compose.yml"
python3 -c "
import sys
p = '$NEG_DIR/docker-compose.yml'
s = open(p).read().replace('127.0.0.1:5432:5432', '0.0.0.0:5432:5432')
open(p, 'w').write(s)
"
"${DATARIM_RUNTIME:-$HOME/.claude}/dev-tools/network-exposure-check.sh" --compose "$NEG_DIR/docker-compose.yml"
echo "exit=$?"
rm -rf "$NEG_DIR"
If the negative case does not fail, the gate is not actually wired into the
path being tested — stop and investigate before opening the PR.
References
security-baseline.md — Datarim Security Mandate (S1–S9), pre-commit gate.
file-sync-config.md — pre-flight checklist style template.
- CIS Docker Benchmark — Docker container hardening.
- NIST SP 800-204 — service security baseline.
- OWASP Cloud Top 10.