From 85d765ff565f95a5ea7de6714af9a6e0df7c73e0 Mon Sep 17 00:00:00 2001 From: batmanknows <122247678+batmnnn@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:07:23 +0530 Subject: [PATCH] Harden serve defaults and close XSS/path injection sinks. Make tracer serve loopback-only with opt-in CORS and request/batch caps, escape untrusted report/scan content (including sankey/viz), and allowlist watch sink filenames so local telemetry cannot traverse the filesystem. Co-authored-by: Cursor --- .gitignore | 12 ++++ AGENTS.md | 3 +- SECURITY.md | 14 ++++ docs/cli.md | 5 +- docs/javascript.md | 2 +- js/src/index.ts | 25 ++++++- js/test/watch.test.ts | 7 ++ src/tracer/analysis/html_report.py | 68 ++++++++++++------- src/tracer/analysis/sankey.py | 22 ++++-- src/tracer/cli/main.py | 32 +++++++-- src/tracer/policy/artifacts.py | 8 +++ src/tracer/runtime/serve.py | 105 +++++++++++++++++++++++++++-- src/tracer/scanner.py | 10 ++- src/tracer/watch.py | 28 +++++++- tests/test_html_report_escape.py | 100 +++++++++++++++++++++++++++ tests/test_serve.py | 100 ++++++++++++++++++++++++++- tests/test_watch.py | 14 ++++ 17 files changed, 504 insertions(+), 51 deletions(-) create mode 100644 tests/test_html_report_escape.py diff --git a/.gitignore b/.gitignore index e3523c1..f974eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,15 @@ notebooks/data/ # Node (JS package) node_modules/ + +# Secrets / credentials (never commit) +.env +.env.* +!.env.example +*.pem +*.key +credentials.json +cloud.json + +# Local agent/security tooling +.superstack/ diff --git a/AGENTS.md b/AGENTS.md index 5a11e2d..da4c3d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,8 +155,9 @@ router = tracer.load_router(".tracer", embedder=my_embedder) ### Pattern 4: HTTP server (zero deps) ```bash -tracer serve .tracer --port 8000 +tracer serve .tracer --port 8000 # binds 127.0.0.1 by default (no auth) # POST /predict {"embedding": [...]} → {"label", "decision", "accept_score"} +# Only use --host 0.0.0.0 behind a reverse proxy with TLS/auth. .tracer artifacts are trusted code (joblib/pickle). ``` --- diff --git a/SECURITY.md b/SECURITY.md index ec50741..214659c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,4 +45,18 @@ own API keys; issues in third-party dependencies, model providers, or your own deployment configuration are out of scope here — please report those to the relevant vendor. +## Trust model (operators) + +A few boundaries that matter when deploying Tracer: + +- **`.tracer` artifacts are trusted code.** `pipeline.joblib` is loaded with + joblib/pickle. Only load artifacts you produced (or otherwise fully trust). + Treat a foreign `.tracer/` directory like an untrusted binary. +- **`tracer serve` has no authentication.** It binds to `127.0.0.1` by default. + If you bind to `0.0.0.0` (or otherwise expose the port), put a reverse proxy + with TLS and auth in front. CORS is off unless you pass `--cors-origin`. +- **`tracer.watch` / `@tracer-llm/watch` may exfiltrate prompts.** When + `TRACER_CLOUD_KEY` (or an OTLP endpoint) is set, full input/output text is + sent to that destination. Keep those env vars under your control. + Thank you for helping keep Tracer and its users safe. diff --git a/docs/cli.md b/docs/cli.md index 830012f..f87bdef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -339,7 +339,10 @@ tracer serve [] [--host ] [--port ] | Argument | Default | Description | |----------|---------|-------------| | `artifact-dir` | `.tracer` | Artifact directory | -| `--host` | `0.0.0.0` | Bind address | +| `--host` | `127.0.0.1` | Bind address (loopback; use `0.0.0.0` only behind a reverse proxy with auth) | +| `--cors-origin` | off | If set, emit `Access-Control-Allow-Origin` (e.g. `https://app.example.com` or `*`) | +| `--max-body-bytes` | `16777216` | Reject larger request bodies | +| `--max-batch` | `10000` | Reject `/predict_batch` with more rows | | `--port` | `8000` | Listen port | **Endpoints:** diff --git a/docs/javascript.md b/docs/javascript.md index 6fe06af..8739d76 100644 --- a/docs/javascript.md +++ b/docs/javascript.md @@ -81,7 +81,7 @@ TRACER reads `traces.jsonl` and auto-discovers `traces.npy` (same stem, `.npy` e tracer serve .tracer --port 8000 ``` -Run this as a sidecar next to your Node app, same server, same docker-compose, same machine. It binds to `0.0.0.0:8000` by default. +Run this as a sidecar next to your Node app, same server, same docker-compose, same machine. It binds to `127.0.0.1:8000` by default (pass `--host 0.0.0.0` only behind a reverse proxy with auth). --- diff --git a/js/src/index.ts b/js/src/index.ts index 0889b38..55577ca 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -372,16 +372,35 @@ function getEnv(name: string): string | undefined { return g.process?.env?.[name]; } +const WATCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +/** Allowlist watcher names used as filenames (no path separators / traversal). */ +export function safeWatchName(name: string): string { + if (typeof name !== "string" || !WATCH_NAME_RE.test(name)) { + throw new Error( + `invalid watch name ${JSON.stringify(name)}: use 1-128 chars of [A-Za-z0-9._-], starting with alphanumeric`, + ); + } + return name; +} + /** Append spans as JSONL to `/.jsonl`. No network, no key. */ export class LocalFileSink implements Sink { readonly path: string; constructor(name: string, dir: string = ".tracer/watch") { - this.path = path.join(dir, `${name}.jsonl`); + const safe = safeWatchName(name); + const root = path.resolve(dir); try { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(root, { recursive: true }); } catch (e) { - debugLog(`could not create dir ${dir}: ${String(e)}`); + debugLog(`could not create dir ${root}: ${String(e)}`); + } + const full = path.resolve(root, `${safe}.jsonl`); + const rel = path.relative(root, full); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`watch path escapes directory: ${JSON.stringify(name)}`); } + this.path = full; } emit(span: GenAISpan): void { diff --git a/js/test/watch.test.ts b/js/test/watch.test.ts index 644c006..faca69a 100644 --- a/js/test/watch.test.ts +++ b/js/test/watch.test.ts @@ -71,6 +71,13 @@ describe("GenAISpan", () => { // ----- LocalFileSink --------------------------------------------------------- // describe("LocalFileSink", () => { + it("rejects unsafe watch names (allowlist)", () => { + const dir = tmpDir(); + for (const bad of ["../evil", "..\\evil", "a/b", "..", "", "has space", "-leading"]) { + expect(() => new LocalFileSink(bad, dir)).toThrow(/invalid watch name/); + } + }); + it("writes JSONL", () => { const dir = tmpDir(); const sink = new LocalFileSink("unit", dir); diff --git a/src/tracer/analysis/html_report.py b/src/tracer/analysis/html_report.py index 76e9ba8..3aca638 100644 --- a/src/tracer/analysis/html_report.py +++ b/src/tracer/analysis/html_report.py @@ -3,9 +3,18 @@ from __future__ import annotations import json +from html import escape as _html_escape from pathlib import Path from typing import Union +# Pin Plotly (no "latest"); only injected when a sankey div is rendered. +_PLOTLY_CDN = "https://cdn.plot.ly/plotly-2.35.2.min.js" + + +def _esc(value) -> str: + """HTML-escape dynamic report fields (XSS-safe).""" + return _html_escape("" if value is None else str(value), quote=True) + _CSS = """ * { box-sizing: border-box; margin: 0; padding: 0; } @@ -222,11 +231,26 @@ def generate_html_report( method = manifest_raw.get("selected_method") or "none" coverage = manifest_raw.get("coverage_cal") ta = manifest_raw.get("teacher_agreement_cal") - n_traces = manifest_raw.get("n_traces", 0) - n_labels = len(manifest_raw.get("label_space", [])) - emb_dim = manifest_raw.get("embedding_dim") - target_ta = manifest_raw.get("target_teacher_agreement", 0.90) - method_cls = _METHOD_BADGE.get(method, "b-gray") + # Coerce numerics — manifest fields are untrusted if the artifact is. + try: + n_traces = int(manifest_raw.get("n_traces", 0) or 0) + except (TypeError, ValueError): + n_traces = 0 + label_space = manifest_raw.get("label_space") or [] + n_labels = len(label_space) if isinstance(label_space, list) else 0 + try: + emb_dim = int(manifest_raw["embedding_dim"]) if manifest_raw.get("embedding_dim") is not None else None + except (TypeError, ValueError): + emb_dim = None + try: + target_ta = float(manifest_raw.get("target_teacher_agreement", 0.90) or 0.90) + except (TypeError, ValueError): + target_ta = 0.90 + # Only allow known badge classes (manifest method is untrusted input). + method_cls = _METHOD_BADGE.get(method) or "b-gray" + if method_cls not in ("b-green", "b-blue", "b-purple", "b-gray"): + method_cls = "b-gray" + emb_dim_disp = str(emb_dim) if emb_dim is not None else "?" cov_exact = (coverage or 0) * 100 defer_exact = 100 - cov_exact @@ -261,10 +285,7 @@ def generate_html_report( Tracer routing report - - -
@@ -278,7 +299,7 @@ def generate_html_report(

Routing Policy

{n_traces:,} traces  ·  {n_labels} labels -  ·  {emb_dim}-dim embeddings +  ·  {_esc(emb_dim_disp)}-dim embeddings  ·  target TA = {target_ta:.0%}
@@ -296,7 +317,7 @@ def generate_html_report(
Method
- {method.upper()} + {_esc(method.upper())}
selected pipeline
@@ -355,6 +376,7 @@ def generate_html_report( from tracer.analysis.sankey import generate_sankey_div sankey_div = generate_sankey_div(artifact_dir) if sankey_div: + html += f'\n' html += f"""

Routing Flow

@@ -389,7 +411,7 @@ def generate_html_report( """ for s in sorted(label_slices, key=lambda x: -x["handled_rate"]): - label = s["slice_name"].replace("label:", "") + label = _esc(s["slice_name"].replace("label:", "")) hr_v = s["handled_rate"] ta_s = s.get("teacher_agreement_handled") ta_str = f"{ta_s:.1%}" if ta_s is not None else "-" @@ -397,8 +419,8 @@ def generate_html_report( f'{label}' f'{hr_v:.1%}' f'{_bar_html(hr_v)}' - f'{s["count"]}' - f'{ta_str}\n' + f'{int(s["count"])}' + f'{_esc(ta_str)}\n' ) html += " \n \n
\n" @@ -408,10 +430,10 @@ def generate_html_report( html += 'BucketCoverageCount\n' for s in length_slices: html += ( - f'{s["slice_name"]}' + f'{_esc(s["slice_name"])}' f'{s["handled_rate"]:.1%}' f'{_bar_html(s["handled_rate"])}' - f'{s["count"]}\n' + f'{int(s["count"])}\n' ) html += '\n
\n' @@ -423,14 +445,14 @@ def generate_html_report( hs = _score_str(p.get("handled_score")) ds = _score_str(p.get("deferred_score")) html += f"""
-
{p["teacher_label"]}
+
{_esc(p["teacher_label"])}
SURROGATE - {p["handled_preview"]}{hs} + {_esc(p["handled_preview"])}{hs}
→ LLM - {p["deferred_preview"]}{ds} + {_esc(p["deferred_preview"])}{ds}
\n""" html += '\n' @@ -443,18 +465,18 @@ def generate_html_report( for ex in handled_ex[:6]: score_str = f'score {ex["accept_score"]:.2f}' if ex.get("accept_score") else "" html += (f'
' - f'
{ex["input_preview"]}
' + f'
{_esc(ex["input_preview"])}
' f'
' - f'{ex["teacher_label"]}' + f'{_esc(ex["teacher_label"])}' f'{score_str}
\n') html += '\n' html += '
\n

Deferred to teacher

\n' for ex in deferred_ex[:6]: html += (f'
' - f'
{ex["input_preview"]}
' + f'
{_esc(ex["input_preview"])}
' f'
' - f'{ex["teacher_label"]}' + f'{_esc(ex["teacher_label"])}' f'
\n') html += '
\n' @@ -470,7 +492,7 @@ def generate_html_report( prev_bar = int(d["previous_handled_rate"] * 60) cur_bar = int(d["current_handled_rate"] * 60) html += ( - f'{d["label"]}' + f'{_esc(d["label"])}' f'{d["previous_handled_rate"]:.1%}' f'
' f'{d["current_handled_rate"]:.1%}' diff --git a/src/tracer/analysis/sankey.py b/src/tracer/analysis/sankey.py index efd0518..b027d88 100644 --- a/src/tracer/analysis/sankey.py +++ b/src/tracer/analysis/sankey.py @@ -3,9 +3,20 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Optional, Union +# Labels are embedded into Plotly HTML/JS. Strip markup metacharacters so a +# poisoned teacher label cannot break out of the inline ', + }] + qr["boundary_pairs"] = [{ + "teacher_label": payload, + "handled_preview": payload, + "deferred_preview": payload, + "handled_score": 0.9, + "deferred_score": 0.1, + }] + qr_path.write_text(json.dumps(qr)) + return artifact + + +def test_html_report_escapes_trace_text(tmp_path): + artifact = _fit_with_poisoned_label(tmp_path) + out = Path(generate_html_report(artifact)) + html = out.read_text(encoding="utf-8") + assert "" not in html + assert "" not in html + assert "<img src=x onerror=alert(1)>" in html + assert "plotly-latest" not in html + + +def test_sankey_labels_strip_markup(): + from tracer.analysis.sankey import _safe_node_label + assert "<" not in _safe_node_label('') + assert _safe_node_label("normal_label") == "normal_label" + + +def test_html_report_escapes_poisoned_embedding_dim(tmp_path): + artifact = _fit_with_poisoned_label(tmp_path) + manifest_path = artifact / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["embedding_dim"] = '' + manifest_path.write_text(json.dumps(manifest)) + html = Path(generate_html_report(artifact)).read_text(encoding="utf-8") + assert "" not in html + # Non-int embedding_dim is coerced away → "?" display, not raw payload. + assert "?-dim embeddings" in html + + +def test_scanner_viz_json_escapes_script_breakout(): + from tracer.scanner import ScanResult, scan_html + payload = "" + r = ScanResult( + n_traces=10, + n_classes=1, + n_clusters=1, + target=0.9, + certifiable_share=0.5, + certified_floor=0.5, + clusters=[], + forced=False, + projection={ + "points": [{"x": 0, "y": 0, "z": 0, "c": 0, "t": payload}], + "clusters": {"0": {"label": payload, "examples": [payload]}}, + }, + ) + html = scan_html(r, source_name="unit") + assert "" not in html + assert "\\u003c" in html diff --git a/tests/test_serve.py b/tests/test_serve.py index 99ca0c5..3b47342 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -19,7 +19,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) -import tracer +from tracer.api import fit +from tracer.config import FitConfig from tracer.runtime import serve as serve_mod from tracer.policy.artifacts import load_manifest from tracer.runtime.router import Router @@ -51,8 +52,8 @@ def fitted_artifact(tmp_path_factory): tmpdir = tmp_path_factory.mktemp("serve_artifact") traces_path, X = _make_traces(tmpdir) artifact_dir = Path(tmpdir) / ".tracer" - result = tracer.fit(traces_path, artifact_dir, embeddings=X, - config=tracer.FitConfig(verbose=False)) + result = fit(traces_path, artifact_dir, embeddings=X, + config=FitConfig(verbose=False)) assert result.manifest.selected_method is not None, \ "test fixture expected a deployable policy" return artifact_dir, X @@ -65,6 +66,9 @@ def server(fitted_artifact): # can bind an ephemeral port and shut down cleanly. serve_mod._manifest = load_manifest(artifact_dir / "manifest.json") serve_mod._router = Router.load(artifact_dir) + serve_mod._cors_origin = None + serve_mod._max_body_bytes = 16 * 1024 * 1024 + serve_mod._max_batch = 10_000 httpd = HTTPServer(("127.0.0.1", 0), serve_mod._Handler) port = httpd.server_address[1] thread = threading.Thread(target=httpd.serve_forever, daemon=True) @@ -160,3 +164,93 @@ def test_unknown_get_path_is_404(server): def test_unknown_post_path_is_404(server): status, body = _post(server, "/nope", {"x": 1}) assert status == 404 + + +def test_default_serve_host_is_loopback(): + import inspect + from tracer.runtime.serve import serve + assert inspect.signature(serve).parameters["host"].default == "127.0.0.1" + + +def test_oversized_body_is_413(server): + serve_mod._max_body_bytes = 64 + try: + payload = {"embedding": [0.1] * 100} + raw = json.dumps(payload).encode() + assert len(raw) > 64 + req = Request( + f"{server}/predict", + data=raw, + headers={"Content-Type": "application/json", "Content-Length": str(len(raw))}, + method="POST", + ) + try: + urlopen(req, timeout=5) + assert False, "expected 413" + except HTTPError as e: + assert e.code == 413 + body = json.loads(e.read()) + assert "too large" in body["error"] + finally: + serve_mod._max_body_bytes = 16 * 1024 * 1024 + + +def test_batch_over_max_is_413(server, fitted_artifact): + _, X = fitted_artifact + serve_mod._max_batch = 2 + try: + status, body = _post(server, "/predict_batch", {"embeddings": X[:5].tolist()}) + assert status == 413 + assert "batch too large" in body["error"] + finally: + serve_mod._max_batch = 10_000 + + +def test_no_cors_header_by_default(server): + with urlopen(f"{server}/health", timeout=5) as resp: + assert resp.headers.get("Access-Control-Allow-Origin") is None + + +def test_cors_origin_when_enabled(server): + serve_mod._cors_origin = "https://app.example.com" + try: + with urlopen(f"{server}/health", timeout=5) as resp: + assert resp.headers.get("Access-Control-Allow-Origin") == "https://app.example.com" + finally: + serve_mod._cors_origin = None + + +def test_oversized_body_closes_connection(server): + serve_mod._max_body_bytes = 64 + try: + raw = json.dumps({"embedding": [0.1] * 100}).encode() + req = Request( + f"{server}/predict", + data=raw, + headers={"Content-Type": "application/json", "Content-Length": str(len(raw))}, + method="POST", + ) + try: + urlopen(req, timeout=5) + assert False, "expected 413" + except HTTPError as e: + assert e.code == 413 + assert e.headers.get("Connection", "").lower() == "close" + finally: + serve_mod._max_body_bytes = 16 * 1024 * 1024 + + +def test_cli_serve_host_default_is_loopback(monkeypatch): + captured = {} + + def fake_serve(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("tracer.runtime.serve.serve", fake_serve) + monkeypatch.setattr(sys, "argv", ["tracer", "serve", "/tmp/nonexistent-artifact"]) + from tracer.cli.main import main + main() + assert captured["host"] == "127.0.0.1" + assert captured.get("cors_origin") is None + assert captured["max_body_bytes"] == 16 * 1024 * 1024 + assert captured["max_batch"] == 10_000 diff --git a/tests/test_watch.py b/tests/test_watch.py index 88b9092..1a5e57e 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -48,6 +48,20 @@ def test_local_file_sink_writes_jsonl(tmp_path): assert json.loads(lines[0])["input_text"] == "a" +@pytest.mark.parametrize( + "bad", + ["../evil", "..\\evil", "a/b", "a\\b", "..", "", "foo/../bar", "has space", "evil\x00x", "-leading"], +) +def test_local_file_sink_rejects_unsafe_names(tmp_path, bad): + with pytest.raises(ValueError, match="invalid watch name"): + LocalFileSink(bad, dir=str(tmp_path)) + + +def test_local_file_sink_allows_safe_names(tmp_path): + sink = LocalFileSink("support_classifier.v1", dir=str(tmp_path)) + assert sink.path.endswith("support_classifier.v1.jsonl") + + # ----- Watcher decorator + context manager ----------------------------------- # def test_decorator_records_input_output(tmp_path): captured = []