---
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.

```mermaid
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)

```yaml
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:

1. **env-resolve** — if the named environment variable is set, its value is classified. (Resolution is `printenv`-only; the value is never executed.)
2. **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.
3. **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

<details>
<summary>8 side-by-side cases (compose syntax)</summary>

1. **PASS**: `127.0.0.1:5432:5432` (Tier 1)
2. **PASS**: `100.65.1.5:5432:5432` (Tier 2 Tailscale)
3. **PASS**: `[::1]:5432:5432` (Tier 1 IPv6)
4. **PASS**: `0.0.0.0:443:443` + valid justification + expires (Tier 3 justified)
5. **FAIL**: `0.0.0.0:6379:6379` — no justification (Tier 3 unjustified)
6. **FAIL**: `5432:5432` — short-form (implicit Tier 3)
7. **FAIL**: `0.0.0.0:443:443` + expires=2025-01-01 (expired)
8. **FAIL**: `[::]:5432:5432` (IPv6 unspecified)

</details>

## Anti-patterns

<details>
<summary>Common mistakes that trigger FAIL</summary>

- 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.

</details>

## Verifier Integration

Verification program: `"${DATARIM_RUNTIME:?}/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:

```bash
"${DATARIM_RUNTIME:?}/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`          |
| `critical` (word-form alias of `P0`) | (any)                                                      | (any)                    | `hard_block`    |
| `high` (word-form alias of `P1`) | sec/infra types (as `P1` row)                                  | (any)                    | `hard_block`    |
| `high` (word-form alias of `P1`) | others                                                         | (any)                    | `advisory_warn` |
| 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.

**Word-form priority aliases.** Some task-descriptions express priority as the word forms `critical` / `high` rather than the `Pn` scale. The gate accepts these as first-class aliases — `critical` ≡ `P0` (absolute floor), `high` ≡ `P1` (type-refined) — so such a file is consciously handled instead of fail-closing to `hard_block` as "malformed" (the latent-false-positive class a plain `high` non-sec task would otherwise hit). The short-form `security` type is a member of the sec/infra set alongside its long siblings `security-incident` / `security-baseline`. Any other priority value remains malformed → fail-closed `hard_block`.

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:?}/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:

```bash
# nosec-extract
decision=$("${DATARIM_RUNTIME:?}/dev-tools/network-exposure-gate.sh" \
    --task-description datarim/tasks/<TASK-ID>-task-description.md \
    --network-diff \
    --quiet)
case "$decision" in
    hard_block)    # STOP pipeline step
        ;;
    advisory_warn) # print warning, continue
        ;;
    skip)          # silent
        ;;
esac
```

## Machine-parseable Rules Block

```yaml
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    # docker-compose 'ports' entries without a host-IP component
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:

```yaml
jobs:
  network-exposure-baseline:
    needs: [lint, unit-tests]   # array form — list every real prerequisite
    steps:
      - run: "${DATARIM_RUNTIME:?}/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):

```bash
"${DATARIM_RUNTIME:?}/dev-tools/network-exposure-check.sh" --compose docker-compose.yml
echo "exit=$?"   # expect 0 (or the known-baseline exit code)
```

### 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:

```bash
TASK_ID="TUNE-XXXX"                       # substitute the current task id
NEG_DIR="/tmp/${TASK_ID}-negsmoke"
rm -rf "$NEG_DIR" && mkdir -p "$NEG_DIR"
cp docker-compose.yml "$NEG_DIR/docker-compose.yml"

# Portable edit: replace a loopback bind with an unjustified Tier-3 bind.
# sed -i differs between GNU/BSD; the python one-liner below is portable.
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:?}/dev-tools/network-exposure-check.sh" --compose "$NEG_DIR/docker-compose.yml"
echo "exit=$?"   # expect 1 (Tier-3 violation, no justification) — gate fires

rm -rf "$NEG_DIR"   # clean up the scratch copy, do not leave it behind
```

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.
