diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f89a12..924aadf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,11 @@ on: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + services: postgres: image: postgres:16 @@ -38,17 +43,19 @@ jobs: --health-timeout 5s --health-retries 15 - env: - POSTGRES_URL: postgresql://gate:gate@localhost:5432/writegate - MYSQL_URL: mysql://gate:gate@127.0.0.1:3306/writegate - steps: - uses: actions/checkout@v4 - - name: Set up Python + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} + + - name: Enable live DB integration (3.12 only) + if: matrix.python-version == '3.12' + run: | + echo "POSTGRES_URL=postgresql://gate:gate@localhost:5432/writegate" >> "$GITHUB_ENV" + echo "MYSQL_URL=mysql://gate:gate@127.0.0.1:3306/writegate" >> "$GITHUB_ENV" - name: Install (editable + extras) run: | @@ -64,14 +71,16 @@ jobs: run: make test - name: Build wheel + if: matrix.python-version == '3.12' run: python -m build - name: Install wheel into clean venv and smoke + if: matrix.python-version == '3.12' run: | python -m venv /tmp/wheel-venv /tmp/wheel-venv/bin/pip install -U pip /tmp/wheel-venv/bin/pip install dist/*.whl - /tmp/wheel-venv/bin/pip install "pytest>=8" pyyaml "duckdb>=1.1" "sqlglot>=25" "psycopg[binary]>=3.1" pymysql + /tmp/wheel-venv/bin/pip install "pytest>=8" pyyaml "duckdb>=1.1" "sqlglot>=25,<31" "psycopg[binary]>=3.1" pymysql /tmp/wheel-venv/bin/python -c "import write_gate; print('installed', write_gate.__version__)" # Installed wheel has no checkout seed; init cwd policy/catalog. Clear service URLs so smoke uses DuckDB. unset POSTGRES_URL MYSQL_URL DATABASE_URL || true diff --git a/.gitignore b/.gitignore index 5b743e4..0d3903c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ seed/orders.csv .logs/demo_audit.jsonl build/ dist/ + +.pytest_tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d8faf7a..b35afea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to **sql-write-gate** are documented here. +## [1.1.3] — 2026-09-09 + +### Security (P0 HTTP trust boundary) +- `serve` locks database / policy / catalog / environment at startup; request bodies may only supply `sql`, `actor`, `model_id`, `prompt_summary`. +- Body fields that override `policy` / `catalog` / `database` / `db_path` / `environment` are rejected (`400 trust_boundary_violation`). +- Non-loopback binds (including `0.0.0.0` / `::`) require `--auth-token` or `SQL_WRITE_GATE_HTTP_TOKEN`; all-interfaces without auth is refused at startup. +- Audit SQL defaults to literal **redact** (`SQL_WRITE_GATE_AUDIT_SQL_MODE=redact|hash|plain`). +- `sqlglot` compatibility ceiling: `>=25,<31`. +- CI matrix covers Python 3.11 and 3.12 (live Postgres/MySQL integration 3.12-only). + ## [1.1.2] — 2026-09-07 ### Docs diff --git a/README.md b/README.md index 1e21ec2..b9d719d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ sql-write-gate check "DELETE FROM orders" - **非生产唯一边界 / 非唯一边界** — 须与最小权限 DB 角色、网络隔离、人工流程并用 - 仅声明矩阵:DuckDB / PostgreSQL / MySQL / SQLite + 已列 SQL;未列语法拒绝 - 不是分布式审批锁、MySQL wire 代理、Web UI、企业 DQ/血缘/多租户平台 -- HTTP `serve` 请求体仍可覆盖 policy/catalog/database(尚未 server-lock);本地固定 flag 的 CLI/MCP 更稳妥 +- HTTP `serve`:policy/catalog/database 已在启动时 server-lock;非 loopback / `0.0.0.0` 须 `--auth-token` / `SQL_WRITE_GATE_HTTP_TOKEN`(详见下方 Trust boundary) - GitHub Latest Release 可能滞后 `main`;以包版本 / commit 为准 --- @@ -148,7 +148,9 @@ sql-write-gate init # scaffold policy.yaml + catalog.json DataPilot calls this gate **outbound**. Prefer MCP `query_sql` / `write_sql`, CLI `check` / `exec` / `proxy`, or HTTP: ```bash -sql-write-gate serve --host 127.0.0.1 --port 8787 +sql-write-gate serve --host 127.0.0.1 --port 8787 \ + --policy policy.yaml --catalog catalog.json --database seed/warehouse.duckdb +# Non-loopback / 0.0.0.0 requires: --auth-token SECRET (or SQL_WRITE_GATE_HTTP_TOKEN) ``` | Method | Path | Behavior | @@ -159,14 +161,15 @@ sql-write-gate serve --host 127.0.0.1 --port 8787 | `POST` | `/v1/block` | Alias of `/v1/check` | | `POST` | `/v1/datapilot` | Alias of `/v1/execute` (1.1 semantics unchanged) | -Request JSON: `{ "sql": "...", "actor"?, "model_id"?, "prompt_summary"?, "database"?, "db_path"?, "catalog"?, "policy"? }`. +Request JSON: `{ "sql": "...", "actor"?, "model_id"?, "prompt_summary"? }` only. `serve` locks `--policy` / `--catalog` / `--database` / environment at startup; body overrides of those fields are **rejected**. Response always includes `action` (`ALLOW` \| `BLOCK` \| `REQUIRE_APPROVAL`), `rule_id`, `reason`, `risk_score`, `risk_factors`, `executed`. Treat anything other than `ALLOW` as non-executing. -### Honest boundaries (current — docs only) +### Trust boundary (HTTP `serve`) -- **HTTP binding is not server-locked yet.** `sql-write-gate serve --policy/--catalog/--database` sets defaults, but each request body may still override `policy` / `catalog` / `database` / `db_path`. Do **not** treat body-supplied paths as a trust boundary in production; next hardening pass will bind these server-side only. CLI/MCP started with fixed flags remain the safer local path today. -- **GitHub Release lags main.** Package / `main` is **1.1.2** (`7dc85dd`); GitHub **Latest Release** is still **v1.0.1**. Prefer install-from-main / pin commit `7dc85dd` for suite acceptance until a v1.1.x Release is cut. +- **Server-locked at startup.** `sql-write-gate serve --policy/--catalog/--database` (plus environment from the locked policy) is bound for the process lifetime. Request bodies may only supply `sql` / `actor` / `model_id` / `prompt_summary`; overrides of `policy` / `catalog` / `database` / `db_path` / `environment` return `400 trust_boundary_violation`. +- **Auth for non-loopback.** Binding `127.0.0.1` / `::1` may omit auth. Non-loopback hosts (including `0.0.0.0` / `::`) **require** `--auth-token` or `SQL_WRITE_GATE_HTTP_TOKEN`; all-interfaces without auth is refused at startup. Present `Authorization: Bearer ` or `X-SQLGuard-Token`. +- **GitHub Release lags main.** Package / `main` tracks the latest commit; GitHub **Latest Release** may lag. Prefer install-from-main / pin the tip SHA for suite acceptance until a matching Release is cut. ### GameStream-style permissions @@ -222,7 +225,7 @@ Anything **not** in this matrix (other warehouses, wire-protocol proxies, distri - Approval state machine (SQLite source of truth + JSONL mirror): `pending`→`executing`→`succeeded`|`failed`|`unknown` (+ `rejected`) - Atomic claim under `fcntl.flock` + SQLite `BEGIN IMMEDIATE` (single-host; fail closed without flock) - Three-state execute outcomes; **`unknown`/`executing` never auto-retried** — use `resolve` or `approve --allow-unknown-retry` after manual DB verify -- JSONL audit (redacts URL passwords; records execute failures / unknown; `request_id` + `approval_id` + `execution_outcome` correlation; rotatable) +- JSONL audit (redacts URL passwords; **SQL literals redacted by default** via `SQL_WRITE_GATE_AUDIT_SQL_MODE=redact|hash|plain`; records execute failures / unknown; `request_id` + `approval_id` + `execution_outcome` correlation; rotatable) ## Platform support matrix @@ -385,6 +388,8 @@ See [CHANGELOG.md](CHANGELOG.md) for version history. | `SQL_WRITE_GATE_RESULT_ROW_LIMIT` | `1000` | Cap SELECT/approve rows (truncate + `truncated=true`) | | `SQL_WRITE_GATE_RESULT_BYTE_LIMIT` | `0` (off) | Hard byte cap on materialized rows (payload ≤ limit, or `ResultOversizeError` when `RESULT_OVERSIZE=block`; oversized single row never returned intact) | | `SQL_WRITE_GATE_RESULT_OVERSIZE` | `truncate` | `truncate` (shrink/omit to keep ≤ byte/row caps) or `block` (`ResultOversizeError`) | +| `SQL_WRITE_GATE_AUDIT_SQL_MODE` | `redact` | Audit SQL storage: `redact` (literal scrub, default), `hash` (`sha256:…`), or `plain` (verbatim) | +| `SQL_WRITE_GATE_HTTP_TOKEN` | (optional on loopback) | Bearer token for `serve`; **required** for non-loopback / `0.0.0.0` binds | | `SQL_WRITE_GATE_AUDIT_MAX_BYTES` | `10 MiB` | Rotate audit / approvals JSONL by size | | `SQL_WRITE_GATE_AUDIT_ROTATE_DAILY` | `false` | Also rotate JSONL per UTC day | | `SQL_WRITE_GATE_REQUEST_ID` | auto uuid4 | Audit correlation id | diff --git a/pyproject.toml b/pyproject.toml index de0608c..1fa0f5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "sql-write-gate" -version = "1.1.2" +version = "1.1.3" description = "SQLGuard — policy firewall for AI agents writing to databases (sql-write-gate)" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.11" dependencies = [ "duckdb>=1.1.0", - "sqlglot>=25.0.0", + "sqlglot>=25.0.0,<31", "pyyaml>=6.0", ] diff --git a/src/write_gate/__init__.py b/src/write_gate/__init__.py index 64e84f3..0a88171 100644 --- a/src/write_gate/__init__.py +++ b/src/write_gate/__init__.py @@ -4,4 +4,4 @@ from write_gate.wrapper import WriteGate __all__ = ["WriteGate", "Evidence", "Decision", "__version__"] -__version__ = "1.1.2" +__version__ = "1.1.3" diff --git a/src/write_gate/api.py b/src/write_gate/api.py index 7f44c87..9f445ea 100644 --- a/src/write_gate/api.py +++ b/src/write_gate/api.py @@ -8,6 +8,14 @@ POST /v1/datapilot alias of /v1/execute (1.1 semantics unchanged) GET /healthz {"ok": true, "product": "SQLGuard", "version": "..."} +Trust boundary (P0): + - ``serve`` locks database / policy / catalog / environment at startup. + - Request bodies may only supply sql / actor / model_id / prompt_summary. + - Body fields that would override policy/catalog/database/db_path/environment + are rejected (400). + - Non-loopback binds (including 0.0.0.0 / ::) require authentication; + binding all-interfaces without a token is refused at startup. + Responses always include ``action`` (ALLOW|BLOCK|REQUIRE_APPROVAL), ``risk_score``, ``risk_factors``, ``rule_id``, ``reason``, and for execute ``executed`` (bool). DataPilot should treat anything other than ALLOW as @@ -16,7 +24,9 @@ from __future__ import annotations +import hmac import json +import os import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any @@ -26,6 +36,95 @@ from write_gate.decision import ACTION_ALLOW from write_gate.wrapper import WriteGate +# Body may only carry request metadata + SQL. Server locks the rest at start. +ALLOWED_BODY_KEYS = frozenset({"sql", "actor", "model_id", "prompt_summary"}) +FORBIDDEN_OVERRIDE_KEYS = frozenset( + { + "policy", + "catalog", + "database", + "db_path", + "db", + "environment", + "policy_path", + "catalog_path", + } +) + +ENV_HTTP_TOKEN = "SQL_WRITE_GATE_HTTP_TOKEN" +LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) +ALL_INTERFACES_HOSTS = frozenset({"0.0.0.0", "::", "[::]", "*"}) + + +class ServeBindError(ValueError): + """Refused HTTP bind (missing auth on non-loopback / all-interfaces).""" + + +def is_loopback_host(host: str | None) -> bool: + h = (host or "").strip().lower() + if h.startswith("[") and h.endswith("]"): + h = h[1:-1] + return h in LOOPBACK_HOSTS + + +def is_all_interfaces_host(host: str | None) -> bool: + h = (host or "").strip().lower() + return h in ALL_INTERFACES_HOSTS + + +def resolve_http_auth_token(explicit: str | None = None) -> str | None: + """Return serve auth token from explicit arg or ``SQL_WRITE_GATE_HTTP_TOKEN``.""" + if explicit is not None and str(explicit).strip(): + return str(explicit).strip() + env = os.environ.get(ENV_HTTP_TOKEN, "") + return env.strip() or None + + +def validate_serve_bind(host: str, auth_token: str | None) -> None: + """Refuse unsafe binds. Non-loopback (incl. 0.0.0.0) requires auth.""" + if is_loopback_host(host): + return + if auth_token: + return + if is_all_interfaces_host(host): + raise ServeBindError( + f"refusing to bind {host!r} without authentication " + f"(set --auth-token or {ENV_HTTP_TOKEN})" + ) + raise ServeBindError( + f"non-loopback bind {host!r} requires authentication " + f"(set --auth-token or {ENV_HTTP_TOKEN})" + ) + + +def extract_request_token(headers: Any) -> str | None: + """Read Bearer token or X-SQLGuard-Token from request headers.""" + if headers is None: + return None + auth = headers.get("Authorization") or headers.get("authorization") or "" + if isinstance(auth, str) and auth.lower().startswith("bearer "): + token = auth[7:].strip() + return token or None + custom = headers.get("X-SQLGuard-Token") or headers.get("x-sqlguard-token") + if custom is not None and str(custom).strip(): + return str(custom).strip() + return None + + +def authorize_http_request( + *, + auth_token: str | None, + headers: Any = None, + presented: str | None = None, +) -> bool: + """True when no server token is configured, or presented token matches.""" + if not auth_token: + return True + got = presented if presented is not None else extract_request_token(headers) + if got is None: + return False + return hmac.compare_digest(got, auth_token) + def decision_response( decision: Any, @@ -47,14 +146,35 @@ def decision_response( return payload -def _gate_from_body(body: dict[str, Any], *, defaults: dict[str, Any]) -> WriteGate: +def _reject_body_overrides(body: dict[str, Any]) -> dict[str, Any] | None: + """Return an error payload if body tries to override server-locked fields.""" + bad = sorted(k for k in body if k in FORBIDDEN_OVERRIDE_KEYS) + if not bad: + return None + return { + "error": "trust_boundary_violation", + "action": "BLOCK", + "rejected_fields": bad, + "detail": ( + "HTTP serve locks policy/catalog/database/environment at startup; " + "request body may only include sql, actor, model_id, prompt_summary" + ), + } + + +def _gate_from_locked( + body: dict[str, Any], + *, + defaults: dict[str, Any], +) -> WriteGate: + """Build WriteGate from server-locked defaults; body only supplies actor metadata.""" return WriteGate( - database=body.get("database") or defaults.get("database"), - db_path=body.get("db_path") or defaults.get("db_path"), - catalog_path=body.get("catalog") or defaults.get("catalog"), - policy_path=body.get("policy") or defaults.get("policy"), - agent=body.get("agent") or defaults.get("agent") or "datapilot", - actor=body.get("actor") or body.get("agent") or "datapilot", + database=defaults.get("database"), + db_path=defaults.get("db_path"), + catalog_path=defaults.get("catalog"), + policy_path=defaults.get("policy"), + agent=defaults.get("agent") or "datapilot", + actor=body.get("actor") or defaults.get("agent") or "datapilot", model_id=body.get("model_id"), prompt_summary=body.get("prompt_summary"), ) @@ -66,8 +186,15 @@ def handle_datapilot_request( body: dict[str, Any] | None, *, defaults: dict[str, Any] | None = None, + auth_token: str | None = None, + headers: Any = None, + presented_token: str | None = None, + require_auth: bool | None = None, ) -> tuple[int, dict[str, Any]]: - """Pure request handler used by the HTTP server and unit tests.""" + """Pure request handler used by the HTTP server and unit tests. + + ``defaults`` are server-locked at serve start (database/policy/catalog/…). + """ defaults = defaults or {} parsed = urlparse(path) route = parsed.path.rstrip("/") or "/" @@ -80,6 +207,22 @@ def handle_datapilot_request( "package": "sql-write-gate", } + # Auth: when a server token is configured, every non-health route needs it. + # Non-loopback binds refuse to start without a token (see validate_serve_bind). + must_auth = require_auth if require_auth is not None else bool(auth_token) + if must_auth and method != "GET": + ok = bool(auth_token) and authorize_http_request( + auth_token=auth_token, + headers=headers, + presented=presented_token, + ) + if not ok: + return 401, { + "error": "unauthorized", + "action": "BLOCK", + "detail": "valid Authorization: Bearer or X-SQLGuard-Token required", + } + if method != "POST" or route not in { "/v1/check", "/v1/execute", @@ -89,13 +232,17 @@ def handle_datapilot_request( return 404, {"error": "not_found", "path": route} body = body or {} + override_err = _reject_body_overrides(body) + if override_err is not None: + return 400, override_err + sql = body.get("sql") if not sql or not isinstance(sql, str): return 400, {"error": "missing_sql", "action": "BLOCK"} # /v1/block → check (evaluate only); /v1/datapilot → execute (ALLOW only). do_execute = route in {"/v1/execute", "/v1/datapilot"} - with _gate_from_body(body, defaults=defaults) as gate: + with _gate_from_locked(body, defaults=defaults) as gate: if do_execute: decision, result = gate.execute(sql) executed = decision.action == ACTION_ALLOW and result is not None @@ -120,6 +267,7 @@ def handle_datapilot_request( class DataPilotHandler(BaseHTTPRequestHandler): server_defaults: dict[str, Any] = {} + server_auth_token: str | None = None def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 sys.stderr.write("sqlguard-api: " + (fmt % args) + "\n") @@ -145,7 +293,12 @@ def _send(self, status: int, payload: dict[str, Any]) -> None: def do_GET(self) -> None: # noqa: N802 status, payload = handle_datapilot_request( - "GET", self.path, None, defaults=self.server_defaults + "GET", + self.path, + None, + defaults=self.server_defaults, + auth_token=self.server_auth_token, + headers=self.headers, ) self._send(status, payload) @@ -155,18 +308,49 @@ def do_POST(self) -> None: # noqa: N802 self.path, self._read_json(), defaults=self.server_defaults, + auth_token=self.server_auth_token, + headers=self.headers, ) self._send(status, payload) +def lock_serve_defaults(defaults: dict[str, Any] | None) -> dict[str, Any]: + """Freeze server-side database/policy/catalog/environment for the process.""" + locked = dict(defaults or {}) + # Resolve environment from the locked policy so body cannot influence it. + policy_path = locked.get("policy") + environment = locked.get("environment") + if environment is None and policy_path: + try: + from write_gate.config import load_policy + + environment = load_policy(policy_path).environment + except Exception: + environment = None + if environment is None: + try: + from write_gate.config import load_policy + + environment = load_policy(None).environment + except Exception: + environment = "production" + locked["environment"] = environment + # Drop any accidental mutable aliases; only locked keys are used by the gate. + return locked + + def serve( host: str = "127.0.0.1", port: int = 8787, *, defaults: dict[str, Any] | None = None, + auth_token: str | None = None, ) -> ThreadingHTTPServer: """Start DataPilot HTTP server (blocking via serve_forever in caller).""" - DataPilotHandler.server_defaults = defaults or {} + token = resolve_http_auth_token(auth_token) + validate_serve_bind(host, token) + DataPilotHandler.server_defaults = lock_serve_defaults(defaults) + DataPilotHandler.server_auth_token = token httpd = ThreadingHTTPServer((host, port), DataPilotHandler) return httpd @@ -176,11 +360,20 @@ def run_serve_cli( port: int = 8787, *, defaults: dict[str, Any] | None = None, + auth_token: str | None = None, ) -> int: - httpd = serve(host, port, defaults=defaults) + try: + httpd = serve(host, port, defaults=defaults, auth_token=auth_token) + except ServeBindError as exc: + sys.stderr.write(f"sqlguard serve: {exc}\n") + return 2 + locked = DataPilotHandler.server_defaults sys.stderr.write( f"SQLGuard DataPilot API listening on http://{host}:{port} " - f"(POST /v1/check|/v1/block|/v1/execute|/v1/datapilot)\n" + f"(POST /v1/check|/v1/block|/v1/execute|/v1/datapilot); " + f"policy/catalog/database/environment locked at startup " + f"(environment={locked.get('environment')!r}" + f"{'; auth=on' if DataPilotHandler.server_auth_token else ''})\n" ) try: httpd.serve_forever() diff --git a/src/write_gate/audit.py b/src/write_gate/audit.py index 6d16c9b..8036463 100644 --- a/src/write_gate/audit.py +++ b/src/write_gate/audit.py @@ -2,7 +2,9 @@ from __future__ import annotations +import hashlib import json +import os import re from datetime import datetime, timezone from pathlib import Path @@ -173,6 +175,76 @@ def resolve_trusted_database_url( return None + +# --- audit SQL privacy (P0) -------------------------------------------------- + +ENV_AUDIT_SQL_MODE = "SQL_WRITE_GATE_AUDIT_SQL_MODE" +AUDIT_SQL_MODES = frozenset({"redact", "hash", "plain"}) +DEFAULT_AUDIT_SQL_MODE = "redact" + +_STRING_LITERAL = re.compile( + r"'(?:''|[^'])*'|\"(?:\\.|[^\\\"])*\"" +) +_NUMBER_LITERAL = re.compile(r"\b\d+(?:\.\d+)?\b") + + +def resolve_audit_sql_mode(explicit: str | None = None) -> str: + """Return audit SQL mode: ``redact`` (default) | ``hash`` | ``plain``. + + Modes: + - ``redact``: replace string/number literals (default; safer logs) + - ``hash``: store ``sha256:`` of the SQL only + - ``plain``: store SQL verbatim (legacy / explicit opt-in) + """ + raw = (explicit if explicit is not None else os.environ.get(ENV_AUDIT_SQL_MODE, "")) or "" + mode = str(raw).strip().lower() or DEFAULT_AUDIT_SQL_MODE + if mode not in AUDIT_SQL_MODES: + return DEFAULT_AUDIT_SQL_MODE + return mode + + +def redact_sql_literals(sql: str) -> str: + """Replace string/number literals for audit storage.""" + text = str(sql or "") + if not text: + return text + try: + import sqlglot + from sqlglot import exp + + trees = sqlglot.parse(text) + parts: list[str] = [] + for tree in trees: + if tree is None: + continue + for node in list(tree.walk()): + if isinstance(node, exp.Literal): + node.replace(exp.Placeholder()) + parts.append(tree.sql()) + if parts: + return "; ".join(parts) + except Exception: + pass + out = _STRING_LITERAL.sub("'?'", text) + out = _NUMBER_LITERAL.sub("?", out) + return out + + +def hash_sql(sql: str) -> str: + digest = hashlib.sha256(str(sql or "").encode("utf-8")).hexdigest() + return f"sha256:{digest}" + + +def format_sql_for_audit(sql: str, *, mode: str | None = None) -> str: + """Apply ``audit_sql_mode`` before writing SQL into the audit JSONL.""" + resolved = resolve_audit_sql_mode(mode) + if resolved == "plain": + return str(sql or "") + if resolved == "hash": + return hash_sql(sql) + return redact_sql_literals(sql) + + def append_audit( decision: Decision, *, @@ -189,6 +261,7 @@ def append_audit( prompt_summary: str | None = None, latency_ms: float | None = None, success: bool | None = None, + audit_sql_mode: str | None = None, ) -> None: """Append one audit JSONL record with correlatable ids (v0.23). @@ -218,7 +291,7 @@ def append_audit( "agent": agent, "actor": actor or agent, "environment": environment, - "sql": decision.sql, + "sql": format_sql_for_audit(decision.sql, mode=audit_sql_mode), "operation": decision.operation, "table": decision.table, "estimated_rows": decision.estimated_rows, diff --git a/src/write_gate/cli.py b/src/write_gate/cli.py index 20e0a55..b1c4bcb 100644 --- a/src/write_gate/cli.py +++ b/src/write_gate/cli.py @@ -341,6 +341,14 @@ def build_parser() -> argparse.ArgumentParser: ) serve_p.add_argument("--host", default="127.0.0.1", help="Bind host") serve_p.add_argument("--port", type=int, default=8787, help="Bind port") + serve_p.add_argument( + "--auth-token", + default=None, + help=( + "HTTP bearer token required for non-loopback binds " + "(or set SQL_WRITE_GATE_HTTP_TOKEN). Loopback may omit auth." + ), + ) dp = sub.add_parser( "datapilot", @@ -661,6 +669,7 @@ def main(argv: list[str] | None = None) -> int: host=args.host, port=args.port, defaults=defaults, + auth_token=getattr(args, "auth_token", None), ) with _gate_from_args(args) as gate: diff --git a/tests/test_audit.py b/tests/test_audit.py index 7555158..ec8c04d 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -3,7 +3,14 @@ import json import re -from write_gate.audit import format_audit_table, format_audit_time, read_audit +from write_gate.audit import ( + format_audit_table, + format_audit_time, + format_sql_for_audit, + hash_sql, + read_audit, + redact_sql_literals, +) from write_gate.cases import LEGAL_WRITE_SQL from write_gate.cli import main from write_gate.config import demo_policy, production_policy @@ -29,7 +36,10 @@ def test_check_appends_audit_jsonl(tmp_path): rec = rows[0] assert rec["agent"] == "test" assert rec["environment"] == "demo" - assert rec["sql"] == LEGAL_WRITE_SQL + # Default audit_sql_mode=redact scrubs literals + assert rec["sql"] == format_sql_for_audit(LEGAL_WRITE_SQL, mode="redact") + assert "18.50" not in rec["sql"] + assert "'2026-09-01'" not in rec["sql"] assert rec["operation"] == "insert" assert rec["table"] == "orders" assert rec["decision"] == "ALLOW" @@ -119,3 +129,37 @@ def test_cli_audit_empty_message(tmp_path, capsys): out = capsys.readouterr().out assert rc == 0 assert "no audit records" in out.lower() + + +def test_audit_sql_mode_redact_hash_plain(tmp_path, monkeypatch): + from write_gate.audit import append_audit + from write_gate.decision import ACTION_ALLOW, Decision + + sql = LEGAL_WRITE_SQL + redacted = redact_sql_literals(sql) + assert "18.50" not in redacted + assert "'2026-09-01'" not in redacted + assert hash_sql(sql).startswith("sha256:") + + audit = tmp_path / "a.jsonl" + decision = Decision( + action=ACTION_ALLOW, + risk="low", + rule_id="ok", + reason="ok", + sql=sql, + operation="insert", + table="orders", + ) + append_audit(decision, path=audit, audit_sql_mode="plain", environment="demo") + assert read_audit(audit, limit=1)[0]["sql"] == sql + + audit2 = tmp_path / "b.jsonl" + append_audit(decision, path=audit2, audit_sql_mode="hash", environment="demo") + assert read_audit(audit2, limit=1)[0]["sql"] == hash_sql(sql) + + monkeypatch.setenv("SQL_WRITE_GATE_AUDIT_SQL_MODE", "plain") + audit3 = tmp_path / "c.jsonl" + append_audit(decision, path=audit3, environment="demo") + assert read_audit(audit3, limit=1)[0]["sql"] == sql + diff --git a/tests/test_p0_http_trust_boundary.py b/tests/test_p0_http_trust_boundary.py new file mode 100644 index 0000000..3be6fa9 --- /dev/null +++ b/tests/test_p0_http_trust_boundary.py @@ -0,0 +1,169 @@ +"""P0 HTTP trust boundary: body cannot override locked serve config; bind auth.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from write_gate.api import ( + ServeBindError, + authorize_http_request, + handle_datapilot_request, + is_all_interfaces_host, + is_loopback_host, + lock_serve_defaults, + validate_serve_bind, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def _defaults(tmp_path: Path | None = None) -> dict: + return { + "db_path": str(ROOT / "seed" / "warehouse.duckdb"), + "policy": str(ROOT / "examples" / "policy.demo.yaml"), + "agent": "datapilot", + } + + +def test_loopback_hosts_identified(): + assert is_loopback_host("127.0.0.1") + assert is_loopback_host("localhost") + assert is_loopback_host("::1") + assert not is_loopback_host("0.0.0.0") + assert not is_loopback_host("192.168.1.10") + assert is_all_interfaces_host("0.0.0.0") + assert is_all_interfaces_host("::") + + +def test_reject_all_interfaces_without_auth(): + with pytest.raises(ServeBindError, match="0.0.0.0"): + validate_serve_bind("0.0.0.0", None) + with pytest.raises(ServeBindError, match="authentication"): + validate_serve_bind("::", None) + + +def test_reject_non_loopback_without_auth(): + with pytest.raises(ServeBindError, match="non-loopback"): + validate_serve_bind("192.168.1.5", None) + + +def test_allow_loopback_without_auth(): + validate_serve_bind("127.0.0.1", None) + validate_serve_bind("localhost", None) + + +def test_allow_non_loopback_with_auth(): + validate_serve_bind("0.0.0.0", "secret") + validate_serve_bind("10.0.0.2", "secret") + + +def test_body_cannot_override_policy_catalog_db(): + defaults = lock_serve_defaults(_defaults()) + for route in ("/v1/check", "/v1/execute", "/v1/datapilot", "/v1/block"): + for field, value in ( + ("policy", "/tmp/evil-policy.yaml"), + ("catalog", "/tmp/evil-catalog.json"), + ("database", "/tmp/evil.duckdb"), + ("db_path", "/tmp/evil.duckdb"), + ("environment", "demo"), + ): + status, payload = handle_datapilot_request( + "POST", + route, + {"sql": "DELETE FROM orders", field: value}, + defaults=defaults, + ) + assert status == 400, (route, field, payload) + assert payload["error"] == "trust_boundary_violation" + assert field in payload["rejected_fields"] + assert payload["action"] == "BLOCK" + + +def test_body_override_ignored_fields_do_not_change_locked_gate(tmp_path): + """Even if somehow only allowed keys present, locked paths win for evaluate.""" + defaults = lock_serve_defaults(_defaults()) + status, payload = handle_datapilot_request( + "POST", + "/v1/check", + {"sql": "DELETE FROM orders", "actor": "pilot", "model_id": "m"}, + defaults=defaults, + ) + assert status == 200 + assert payload["action"] == "BLOCK" + assert payload["executed"] is False + + +def test_execute_alias_also_rejects_db_override(): + defaults = lock_serve_defaults(_defaults()) + status, payload = handle_datapilot_request( + "POST", + "/v1/execute", + { + "sql": "SELECT 1", + "database": "postgresql://evil/db", + "policy": "/etc/passwd", + }, + defaults=defaults, + ) + assert status == 400 + assert set(payload["rejected_fields"]) >= {"database", "policy"} + + +def test_auth_required_when_token_configured(): + defaults = lock_serve_defaults(_defaults()) + status, payload = handle_datapilot_request( + "POST", + "/v1/check", + {"sql": "DELETE FROM orders"}, + defaults=defaults, + auth_token="s3cret", + ) + assert status == 401 + assert payload["error"] == "unauthorized" + + status, payload = handle_datapilot_request( + "POST", + "/v1/check", + {"sql": "DELETE FROM orders"}, + defaults=defaults, + auth_token="s3cret", + presented_token="wrong", + ) + assert status == 401 + + status, payload = handle_datapilot_request( + "POST", + "/v1/check", + {"sql": "DELETE FROM orders"}, + defaults=defaults, + auth_token="s3cret", + presented_token="s3cret", + ) + assert status == 200 + assert payload["action"] == "BLOCK" + + +def test_authorize_http_request_headers(): + class H(dict): + def get(self, k, default=None): + for key, val in self.items(): + if key.lower() == k.lower(): + return val + return default + + assert authorize_http_request(auth_token=None) is True + assert authorize_http_request(auth_token="t", headers=H()) is False + assert authorize_http_request( + auth_token="t", headers=H({"Authorization": "Bearer t"}) + ) + assert authorize_http_request( + auth_token="t", headers=H({"X-SQLGuard-Token": "t"}) + ) + + +def test_lock_serve_defaults_captures_environment(): + locked = lock_serve_defaults(_defaults()) + assert locked.get("environment") + assert "policy" in locked diff --git a/tests/test_v110.py b/tests/test_v110.py index 9445db9..d5e2782 100644 --- a/tests/test_v110.py +++ b/tests/test_v110.py @@ -20,9 +20,9 @@ def test_version_is_111(): - assert __version__ == "1.1.2" - assert 'version = "1.1.2"' in (ROOT / "pyproject.toml").read_text(encoding="utf-8") - assert "## [1.1.2]" in (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + assert __version__ == "1.1.3" + assert 'version = "1.1.3"' in (ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert "## [1.1.3]" in (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") assert PRODUCT == "SQLGuard" @@ -172,7 +172,7 @@ def test_datapilot_api_check_and_execute(tmp_path): status, health = handle_datapilot_request("GET", "/healthz", None, defaults=defaults) assert status == 200 assert health["ok"] is True - assert health["version"] == "1.1.2" + assert health["version"] == "1.1.3" # Legal insert — use check (no mutate shared seed); execute path covered by demo status, payload = handle_datapilot_request(