Skip to content

No gate covers the bind path: security:lint returns zero errors on a listener bound to all interfaces #2476

Description

@ateles-agent

Problem

scripts/security/semgrep_auth_rules.yml has a rule (loopback-trust-in-production) that guards the loopback boundary on the auth path — it refuses a bare req.socket.remoteAddress check and directs callers to isLocalRequest(). Nothing guards the bind path: whether a listener is actually bound where the configuration claims.

These are different questions. The auth path decides whether we believe a request is local. The bind path decides who can reach the process at all. A clean npm run security:lint run says nothing about the second.

Concretely: npm run security:lint returned 0 errors on a revision where app.listen(port, cb) was called with no host while an environment variable was read to report the instance as loopback-only. The gate was green throughout.

Why it needs a mechanism rather than review attention

Three listeners in this repo were found binding all interfaces, in two of them by default, and the fixes (#2472, #2474) each added their own local resolver. Nothing stops the next listener from omitting a host, and nothing stops a future edit from dropping host from an existing listen call — a resolver unit test stays green when that happens, which is a finding an earlier review already made on #2474.

Suggested shape

  1. A static rule for app.listen / server.listen / new WebSocketServer(...) in non-test code that reaches a listen call without an explicit host argument. Severity ERROR per the mapping in docs/security/threat_model.md, citing the guardrail it enforces.
  2. A stated principle to go with it: a security posture value derives from observed runtime state (server.address()), never from the configuration that was intended. fix(http): bind loopback by default and honor NEOTOMA_HTTP_HOST #2472 implements this for the HTTP listener; the rule should make it general.
  3. Worth considering whether the three resolvers now in the tree (resolveHttpBindHost, resolveProxyBindHost, and the ws-bridge equivalent) should collapse into one, so there is a single place the default lives.

Test the gate, not just the code

A rule that does not fire on the pre-fix revision is decoration. Verify by running the new rule against the parent commit of #2472 and confirming it reports the defect, before wiring it as blocking.

Context

docs/security/threat_model.md lists what the gates do not cover: operational secret rotation, cryptographic correctness, supply chain, social engineering. The bind path is absent from both the covered and the uncovered list, so this gap is currently not acknowledged either way — worth adding to that document alongside the rule.

Related: #2472, #2474, #2257.

Swarm specification

This section is maintained by the Ateles swarm. Each lens agent owns exactly one subsection below; the human-written description above these markers is never modified.

Design basis

Design basis: docs/foundation/principles.md#1-a-mechanism-that-does-not-bind-is-not-a-control — a green security:lint that cannot fail on an all-interfaces bind is reporting, not a control; this issue adds a binding G2 mechanism and (per invariant 3) a planted positive on the parent of #2472.

Product / Scope (PM)

Problem
npm run security:lint (G2) guards the auth path for locality (loopback-trust-in-production) but does not guard the bind path: a listener can omit an explicit host (Node binds all interfaces) while configuration/posture still claims loopback-only — and the gate stays green.

In scope

  • Add a static G2 rule (severity ERROR) that fails on non-test app.listen / server.listen / new WebSocketServer(...) call sites that reach listen without an explicit host argument.
  • Implement the rule on both G2 surfaces: scripts/security/semgrep_auth_rules.yml and the Node fallback in scripts/security/run_semgrep.js (same rule id, same ERROR severity).
  • Cite the guardrail / threat-model MUST the rule enforces in the rule message (per existing ERROR promotion rules).
  • State the principle: security posture values derive from observed runtime state (server.address()), not intended configuration alone; document the bind-path gap in docs/security/threat_model.md (covered and/or uncovered lists — currently absent from both).
  • Wire the rule so a failing hit fails npm run security:lint / security_gates like other ERROR rules.

Out of scope

Acceptance criteria

  • New ERROR rule exists for listen / WebSocketServer without explicit host in non-test production paths, with a message that cites the guardrail it enforces.
  • Effect-verified (planted positive): running the new rule against the parent commit of fix(http): bind loopback by default and honor NEOTOMA_HTTP_HOST #2472 reports the defective app.listen(port, cb) (or equivalent) — documented in the PR; a clean main (post-fix) does not false-fail on intentional explicit-host call sites.
  • Cross-surface parity: the same rule id fires with ERROR on both Semgrep YAML (when NEOTOMA_SECURITY_USE_SEMGREP=1) and the default Node runner in run_semgrep.js, each driven with that surface's natural invocation.
  • npm run security:lint exits non-zero when the planted-positive fixture/revision is scanned; exits zero on current compliant main listeners that pass an explicit host.
  • docs/security/threat_model.md names the bind path (what G2 now covers and/or what remains uncovered), so the gap is no longer unacknowledged.
  • Escape hatches (if any) follow existing G2 patterns (neotoma:security-allow:<rule-id> / documented allowlist) and cannot be added silently.

Priority & sequencing

Open questions (blocking)

  • None for PM sequencing. Resolver consolidation is explicitly deferred.

Design / UX

Acceptance checklist

  • Use one canonical rule id on both engines: explicit-listener-bind-host.
  • Every finding shows ERROR, rule id, file and line, offending call, risk, accepted remediation, and threat-model reference.
  • Output identifies the engine used (node-fallback or semgrep) and reports files scanned.
  • A zero-file scan is an invocation error, not a clean result.
  • A clean run says “no findings”; it must not imply that the runtime bind is verified or secure.
  • Suppressions show rule id, location, and non-empty reason in human and JSON output.
  • Human output remains understandable without color. JSON exposes equivalent fields for agents.
  • Required documentation includes failing, passing, suppression, clean, and invocation-error examples for both engines.

Prior art checked

#2472 and #2474 repair specific listeners. No existing issue, PR, or G2 rule covers omitted bind hosts as a class.

User goal

A developer or agent must be able to identify why security:lint rejected a listener, apply an accepted host-bearing form without knowing Node overload semantics, and verify which static-analysis surface produced the result.

Developer flow

  1. Run npm run security:lint or the documented Semgrep invocation.

  2. The command identifies its engine and scan coverage.

  3. On a finding, the first line remains stable and parseable:

    ERROR explicit-listener-bind-host <path>:<line>
    
  4. The following lines provide:

    [COPY: Explain that the listener omits an explicit bind host and may therefore accept connections on all interfaces.]
    Fix: pass an explicit host argument or host option; after binding, derive reported posture from server.address().
    Guardrail: docs/security/threat_model.md#listener-bind-boundary
    Match: <offending expression>
    
  5. After remediation, rerunning the same command produces a scoped clean state containing the engine, rules evaluated, and files scanned.

  6. If an exception is necessary, the developer uses // neotoma:security-allow:explicit-listener-bind-host <reason> on the preceding line. The run reports the suppression. An absent reason must not suppress the finding.

Error and empty states

  • P0: A diagnostic names the rule but not an accepted host-bearing form. The developer cannot complete the task without reverse-engineering the matcher.
  • P0: Semgrep and the Node fallback disagree on the rule id, severity, or accepted forms.
  • P1: A clean result does not identify the engine or scan coverage. The developer cannot distinguish success from an unwired or empty scan.
  • P1: A suppression disappears from output. Reviewers cannot distinguish an exception from coverage.
  • P2: The documentation explains intent but provides no failing and passing examples for each guarded listener shape.

Documentation and examples

  • Update docs/security/threat_model.md with a stable Listener bind boundary heading that states what G2 detects, the observed-runtime-posture principle, and the static rule’s limits.
  • Update docs/security/practices.md under G2 with the rule id, severity, command, remediation path, suppression syntax, and known limitations. Do not create a separate standalone guide.
  • Ship complete, executable fixtures or examples covering:
    • failing app.listen(port, callback);
    • failing server.listen(...) without a host;
    • failing new WebSocketServer({ port });
    • passing positional and options-object forms with explicit hosts;
    • an explicit non-loopback opt-in;
    • a reported server.address() read-back;
    • a reasoned suppression;
    • clean, finding, suppressed, zero-file, and invocation-error output.
  • Keep the actionable meaning equivalent across human and JSON output. JSON must expose at least engine, rule_id, severity, file, line, match, hint, guardrail, and suppression state.

Engineering

Goal. Make G2 fail on non-test listener bind sites that omit an explicit host, on both Semgrep YAML and the Node fallback, with Design’s output/suppression contract and a planted-positive effect check against the parent of #2472.

Merge prerequisite (hard). Do not enable this rule as a blocking ERROR on default CI paths until production call sites that the expanded scanner covers already pass an explicit host (or use a reasoned neotoma:security-allow:explicit-listener-bind-host with a non-empty reason). Today on tip-of-main that means at least: src/actions.ts tryListen → app.listen(port, cb) (fixed on #2472 / fix/bind-loopback-default), scripts/dev-proxy.js all-interfaces host (fixed on #2474), and src/mcp_ws_bridge.ts new WebSocketServer({ port }) (still open — treat as a peer prerequisite or a same-PR minimal host/server bind; do not leave a known failing production site green via silent path exclude). Resolver consolidation is out of scope.

Files / modules to touch

Path Change
scripts/security/semgrep_auth_rules.yml Add rule id explicit-listener-bind-host, severity ERROR, languages typescript + javascript, paths include src/** and scripts/**, exclude tests/** and fixture dirs used only for planted positives.
scripts/security/run_semgrep.js Add matching RULES entry (same id, severity: "error"); implement matcher; expand file discovery; tighten suppressions; align human/JSON output with Design.
scripts/security/fixtures/explicit-listener-bind-host/ New planted-positive + passing + suppression fixtures (.ts / .js). Not on the default CI walk.
tests/security/explicit_listener_bind_host.test.ts Effect + cross-surface + empty-scan tests.
docs/security/threat_model.md New covered channel + stable heading Listener bind boundary (#listener-bind-boundary); name static limits in uncovered list.
docs/security/practices.md G2 table row for this rule id; remediation; suppression; limitations; worked output examples for both engines.
.claude/rules/change_guardrails_rules.md New MUST: production listeners MUST pass an explicit bind host (or attach to an already-bound server); cite docs/security/threat_model.md#listener-bind-boundary.
Generated test catalog After adding the test file: npm run generate:test-catalog and commit.

No OpenAPI / schema / MCP / CLI contract changes.

Rule semantics (both engines, identical)

  • Rule id: explicit-listener-bind-host (canonical; Design).
  • Severity: ERROR → exit 1 from npm run security:lint / security_gates.
  • Message MUST include: risk (omit host → Node binds all interfaces), accepted remediations, and Guardrail: docs/security/threat_model.md#listener-bind-boundary (plus the new change_guardrails MUST number).
  • Principle in docs (not enforced by the static matcher): reported posture derives from server.address() after bind, never from intended config alone.

Fail (no explicit host):

  1. *.listen(<port>) / *.listen(<port>, <callback>) — second arg is a function (or omitted).
  2. *.listen(<port>, <backlog>) — second arg is a numeric literal/identifier used as backlog (no host string).
  3. *.listen({ port: … }) / options object without host, hostname, or path (Unix socket) key.
  4. new WebSocketServer({ port: … }) without host / hostname and without server (attach-to-existing).

Pass:

  1. Positional host string: listen(port, host) / listen(port, host, cb).
  2. Options with explicit host / hostname / path (incl. intentional non-loopback e.g. "0.0.0.0").
  3. new WebSocketServer({ port, host }) or { server: existingServer }.
  4. Test trees / *.test.ts / fixture dirs excluded from default scan.

Out of matcher scope (document as limitation): dynamic host variables are fine if present as an argument/property (rule checks presence of a host-bearing argument, not that the value is loopback); runtime server.address() drift vs intent is not statically proven.

Node fallback (run_semgrep.js) — concrete work

  1. File discovery
    • Default walk: src/**/*.ts and scripts/**/*.{js,mjs,cjs,ts} (skip node_modules, dist, .git, scripts/security/fixtures/**).
    • --paths: accept .ts and .js/.mjs/.cjs (today drops non-.ts — fix that).
    • Zero surviving files after resolution → exit 2 (invocation error), never “no findings”.
  2. Matcher — prefer a small shared helper (e.g. matchExplicitListenerBindHost(text)) used by the RULES test fn; regex/AST-lite is OK if fixtures prove parity with Semgrep patterns. Record { index, match }.
  3. Suppression — change suppressed() so // neotoma:security-allow:explicit-listener-bind-host requires a non-empty reason token after the rule id; bare id does not suppress. Emit suppressions in human + JSON (rule id, file, line, reason).
  4. Output (Design)
    • Banner: engine node-fallback or semgrep, rules evaluated, files scanned.
    • Finding first line: ERROR explicit-listener-bind-host <path>:<line> then risk / Fix / Guardrail / Match lines.
    • Clean: “no findings” plus engine + counts; must not claim runtime bind verified.
    • JSON: engine, rule_id, severity, file, line, match, hint, guardrail, suppression fields, files_scanned, rules_evaluated.
  5. Semgrep delegation (maybeRunSemgrep): when NEOTOMA_SECURITY_USE_SEMGREP=1, pass configs that cover the same path globs as the Node walk (not src/ only), and ensure Semgrep ERROR still fails the process.

Semgrep YAML — concrete patterns

Add pattern-either / patterns covering the fail shapes above for TS and JS. Use pattern-not / metavariable filters so options objects with host/hostname/path/server do not match. Mirror exclude globs. Message text must match Node summary/hint/guardrail content (same rule id).

Fixtures

Under scripts/security/fixtures/explicit-listener-bind-host/ (default-scan excluded):

  • fail_app_listen_port_cb.ts — app.listen(port, () => {}) (shape of pre-fix(http): bind loopback by default and honor NEOTOMA_HTTP_HOST #2472 tryListen).
  • fail_server_listen_no_host.ts — server.listen(port).
  • fail_wss_port_only.ts — new WebSocketServer({ port }).
  • pass_listen_positional_host.ts, pass_listen_options_host.ts, pass_wss_host.ts, pass_wss_server_attach.ts, pass_explicit_all_interfaces.ts (host: "0.0.0.0").
  • pass_address_readback.ts — listen with host + server.address() (documents posture principle; must not false-fail).
  • suppress_with_reason.ts / suppress_missing_reason.ts.

Planted-positive note in PR body: parent commit of #2472 first commit is 6c29b6451c4e7c79000472d2fe6f89e260e8cd4d (app.listen(port, () => { in tryListen). Fixture must be byte-equivalent in call shape; optional one-shot verification: git show 6c29b6451^:src/actions.ts snippet vs fixture.

Tests (effect + cross-surface)

tests/security/explicit_listener_bind_host.test.ts:

  1. Effect (Node): node scripts/security/run_semgrep.js --paths <fail fixtures> → exit 1, finding rule_id === "explicit-listener-bind-host", severity error, Match line present.
  2. Pass set: same runner on pass fixtures → exit 0, no findings for this rule.
  3. Suppression: reasoned allow → exit 0 + suppression reported; missing reason → still ERROR.
  4. Cross-surface: with NEOTOMA_SECURITY_USE_SEMGREP=1 and semgrep on PATH, same fail fixture yields same rule id + ERROR; if Semgrep absent, skip that case with an explicit message (Node path remains mandatory).
  5. Invocation error: empty --paths / zero existing files → exit 2.
  6. Clean banner: JSON/--json includes engine and files_scanned.

After adding the test: npm run generate:test-catalog; run npm run security:lint on the PR tip (must be green on default walk); run the new vitest file.

Docs / guardrail

  1. threat_model.md — new § under “Channels covered” for listener bind boundary (G2 rule id, what is detected, observed-server.address() principle); add to “What the gates do not cover” that static analysis does not prove runtime bind equals intended host after env/config mutation.
  2. practices.md — G2 current-rules row + examples (failing/passing/suppressed/clean/invocation-error) for both engines.
  3. change_guardrails_rules.md — MUST entry the rule message cites.

Build-step checklist (PR order)

  1. Confirm merge base: fix(http): bind loopback by default and honor NEOTOMA_HTTP_HOST #2472, fix(dev-proxy): bind loopback by default with an explicit opt-in #2474, and ws-bridge host fix present (or include minimal ws-bridge host in this PR if still open).
  2. Add fixtures under scripts/security/fixtures/explicit-listener-bind-host/.
  3. Implement Semgrep YAML rule + Node RULES entry + shared matcher; expand walk/--paths; fix reason-required suppression; Design output fields.
  4. Update threat_model.md, practices.md, change_guardrails_rules.md.
  5. Add tests/security/explicit_listener_bind_host.test.ts; prove planted-positive shape; prove pass/suppress/exit-2; Semgrep parity when available.
  6. npm run generate:test-catalog (last generated-file step).
  7. Local verify: npm run security:lint (exit 0 on default tree); vitest for the new file; optional NEOTOMA_SECURITY_USE_SEMGREP=1 npm run security:lint.
  8. Open PR (closes #2476); Design basis: docs/foundation/principles.md#1-a-mechanism-that-does-not-bind-is-not-a-control; document planted-positive parent SHA 6c29b6451c4e7c79000472d2fe6f89e260e8cd4d in the PR body.

Non-goals in this PR

  • Collapsing resolveHttpBindHost / resolveProxyBindHost / ws-bridge resolvers.
  • Auth-path / isLocalRequest / loopback-trust-in-production changes.
  • Runtime CI probes of live bind addresses beyond the static rule.

QA / Test Plan

Prior art. No existing tests/security/* coverage of run_semgrep.js. No agentic_eval fixture applies (surface is G2 CLI, not MCP). Related PRs #2472 / #2474 fix listeners only; tip-of-main still fails the intended rule on src/actions.ts tryListen and src/mcp_ws_bridge.ts.

Agent-facing surface. npm run security:lint → node scripts/security/run_semgrep.js (default) and Semgrep YAML when NEOTOMA_SECURITY_USE_SEMGREP=1. Assertions are exit codes + JSON/human finding fields — never free-text prose alone.

Reproducible eval (this is the QA report substrate).

  • Eval id: explicit_listener_bind_host
  • Path: tests/security/explicit_listener_bind_host.test.ts
  • Fixtures: scripts/security/fixtures/explicit-listener-bind-host/ (excluded from default walk)
  • Driver: spawn node scripts/security/run_semgrep.js --json --paths <fixture> (and Semgrep path when available)
  • CI: vitest for that file + npm run security:lint in security_gates; not an agentic_eval JSON (wrong substrate)

Definition of done — effect / regression

  • Planted positive (pre-fix(http): bind loopback by default and honor NEOTOMA_HTTP_HOST #2472): fixture fail_app_listen_port_cb.ts matches call shape of 6c29b6451c4e7c79000472d2fe6f89e260e8cd4d tryListen (app.listen(port, () => {); Node runner exit 1, rule_id === "explicit-listener-bind-host", severity error, Match line present; PR body documents the parent SHA check.
  • Fail shapes (one fixture each → ERROR): *.listen(port), *.listen(port, cb), *.listen(port, backlog) numeric second arg, *.listen({ port }) without host/hostname/path, new WebSocketServer({ port }) without host/hostname/server.
  • Pass shapes (exit 0, no finding for this rule): positional host; options with host / hostname / path; host: "0.0.0.0" explicit opt-in; WebSocketServer({ port, host }); WebSocketServer({ server }); listen+server.address() read-back (no false fail).
  • Suppression: // neotoma:security-allow:explicit-listener-bind-host <non-empty reason> → exit 0 + suppression reported (human + JSON); bare id without reason → still ERROR.
  • Cross-surface parity: same fail fixture under NEOTOMA_SECURITY_USE_SEMGREP=1 yields same rule_id + ERROR; if semgrep absent, skip with explicit message (Node path remains mandatory).
  • Invocation error: zero surviving files / empty --paths → exit 2 (never “no findings”).
  • Output contract (Design): finding first line ERROR explicit-listener-bind-host <path>:<line>; Fix + Guardrail: docs/security/threat_model.md#listener-bind-boundary; JSON includes engine, rule_id, severity, file, line, match, hint, guardrail, suppression fields, files_scanned, rules_evaluated; clean run names engine + counts and must not claim runtime bind verified.

Definition of done — scan scope / false positives

Definition of done — docs (presence checks only)

  • threat_model.md has #listener-bind-boundary and names static limits in uncovered list.
  • practices.md G2 row for this rule id + fail/pass/suppress/clean/invocation-error examples for both engines.
  • change_guardrails_rules.md MUST cited by the rule message.

Out of eval scope (do not block on these)

  • Runtime live-bind probes; resolver consolidation; auth-path / loopback-trust-in-production changes.

qa gate: leave pending until the implementing PR commits the eval above and the run is green; then sign off with eval id + assertions + CI link.

Legal

Risk: low (gate-only static rule + docs; no new data processing, guest tokens, or customer-facing claims).
Licence baseline: MIT (LICENSE / package.json). Resolve product_profile when present; until then treat repo MIT as authoritative for this PR.
Jurisdiction note: locale_profile (profile_key: default) was not found — RGPD/ePrivacy calibration deferred. Not blocking for this PR’s Legal DoD.
Escalation: none for this scope. Recommend qualified counsel only if the PR adds a copyleft / proprietary scanner dependency or customer-facing security marketing claims.

Compliance checklist (PR DoD)

  • Dependencies / licensing — Prefer in-tree edits to scripts/security/semgrep_auth_rules.yml and scripts/security/run_semgrep.js. Add no new runtime npm/pip dependency unless its licence is MIT-compatible (no GPL/AGPL/CC-NC or other copyleft that would taint MIT distribution). If a dependency is added, name it and its licence in the PR body.
  • Secrets / PII surface — Fixtures under scripts/security/fixtures/explicit-listener-bind-host/ are synthetic listen/WSS call shapes only (no real customer hostnames, emails, tokens, or personal data). Finding Match lines and neotoma:security-allow:explicit-listener-bind-host <reason> reason text must not embed secrets, API keys, guest_access_token values, or personal data.
  • Data-handling — No new collection, storage, or transfer of personal data; no privacy-policy or consent-mechanism change. Public docs (docs/security/threat_model.md #listener-bind-boundary, docs/security/practices.md) must not overclaim: a clean security:lint run must not be described as verifying runtime bind (align Design: posture from server.address(), static rule has stated limits).
  • ToS / legal exposure / guest-token & credential scope — Out of scope for this PR: guest-token issuance/scope, access policies, ToS/Privacy Policy text, anchor-offer or case-study claims. neotoma:security-allow suppressions are engineering escape hatches documented in practices — not contractual waivers and not a substitute for operator security policy.

Out of legal scope (do not block Legal on these)

Verdict

  • APPROVE Legal DoD for the specified Eng/QA shape when the checklist above is met.
  • No [BLOCKING] legal items for the current scope. Workflow gate_status.legal stays not_required unless a future workflow_definition sets legal_required: true.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or requestlanius-triageApplied by Lanius triage workflowquestionFurther information is requested

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions