Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ jobs:
- run: npm run test -w @autter/runtime-browser
- run: npm run test -w @autter/otlp-ingester
- run: npm run size -w @autter/runtime-browser
- run: python3 -m unittest discover -s adapters/python -v
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## [Unreleased]

### Security

- Scrub secrets and PII from exception messages, stack traces and span status messages, not only custom attributes; re-scrub every span at export so third-party instrumentations, HTTP URLs (`?token=`) and `recordException` calls are covered; scrub `LlmCallHandle.setAttributes`, `instrumentLlmClient` attributes and provider errors, and module-level captures made before `initAutterServer` (`runtime-node`).

Check notice on line 7 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · markdownlint: MD013

Line length: Expected: 80; Actual: 407 Suggested fix: Fix the markdownlint `MD013` issue at CHANGELOG.md:7: Line length: Expected: 80; Actual: 407

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [deterministic] markdownlint: MD013 — Risk: 30/100

Line length: Expected: 80; Actual: 407

🛠 AI fix prompt (copy & paste into your coding agent)
Fix the markdownlint `MD013` issue at CHANGELOG.md:7: Line length: Expected: 80; Actual: 407

Flagged by Autter security & observability checks.

- New value patterns everywhere: Basic auth, `Authorization:`/`Cookie:`/`Set-Cookie:` header text, connection strings with empty usernames or `@` in the password, `sk-proj-`/`sk-ant-`, Stripe, GitHub fine-grained, GitLab, npm, SendGrid and Google API keys, `password=`/`?token=`/`?api_key=` assignments, Luhn-valid card numbers. New sensitive keys: any `…authorization` header key, `session`/`sessionid`/`sid`, `pwd`, `dsn`, bounded `ssn`. Custom value patterns now apply globally (`runtime-node`, `runtime-browser`, `otlp-ingester`).

Check notice on line 8 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · PII stored without application-level encryption

A sensitive PII column/field (SSN, payment token, secret, API key, password, bank account, etc.) is declared without application-level encryption. Blast radius — exposing this data cascades to the downstream consumers that depend on this code: dependent files `packages/otlp-ingester/src/normalize-otlp.ts`, `packages/otlp-ingester/src/index.ts`, `packages/otlp-ingester/src/auth.ts`, `packages/otlp-ingester/src/sink.ts`, `packages/otlp-ingester/src/normalize-browser.test.ts`, `packages/otlp-ingester/src/normalize.test.ts`, `packages/otlp-ingester/src/server.sink.test.ts`, `packages/otlp-ingester/src/sink.test.ts`. **References:** - https://pages.nist.gov/800-63-3/sp800-63b.html — The secret key and its algorithm SHALL provide at least the minimum security strength specified in the latest revision of [SP 800-131A] (112 bits as of the date ... - https://pages.nist.gov/800-63-FAQ/ — SP 800-63B requires the use of a blacklist of common passwords that are not acceptable for use. We do recommend increased password length as a ... - https://www.pcisecuritystandards.org/documents/PCIDSS_QRGv3_1.pdf — Requirement 3: Protect stored cardholder data Cardholder data should not be stored unless it's necessary to meet the needs of the business. 4: ... Suggested fix: In `CHANGELOG.md` around line 8, a sensitive column/field (SSN, payment token, credit card, secret, API key, password, bank account, etc.) is declared with a plain-text type and no application-level encryption — OR is paired with a broken/weak primitive (MD5/SHA1, low bcrypt cost, low PBKDF2 iterations, DES/RC4/ECB, Math.random for secrets). Store it in an encrypted column (pgp_sym_encrypt / app-level AES-256-GCM via KMS / Vault transit / a dedicated encrypted-field type) and protect passwords with a strong KDF (bcrypt cost ≥ 12, Argon2id with timeCost ≥ 3 and memoryCost ≥ 65536, or PBKDF2-SHA256 with ≥ 600k iterations). Keep the plaintext out of logs, snapshots, and analytics. Blast radius — exposing this data cascades to the downstream consumers that depend on this code: dependent files `packages/otlp-ingester/src/normalize-otlp.ts`, `packages/otlp-ingester/src/index.ts`, `packages/otlp-ingester/src/auth.ts`, `packages/otlp-ingester/src/sink.ts`, `packages/otlp-ingester/src/normalize-browser.test.ts`, `packages/otlp-ingester/src/normalize.test.ts`, `packages/otlp-ingester/src/server.sink.test.ts`, `packages/otlp-ingester/src/sink.test.ts`.

Check notice on line 8 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · markdownlint: MD013

Line length: Expected: 80; Actual: 534 Suggested fix: Fix the markdownlint `MD013` issue at CHANGELOG.md:8: Line length: Expected: 80; Actual: 534
- The browser relay scrubs message, stack, name and nested context (`redact` option) (`runtime-node`).

Check notice on line 9 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · markdownlint: MD013

Line length: Expected: 80; Actual: 102 Suggested fix: Fix the markdownlint `MD013` issue at CHANGELOG.md:9: Line length: Expected: 80; Actual: 102
- Browser SDK scrubs messages, stacks and nested context; new `redact: { keys, values } | false` option and `scrubText` export (`runtime-browser`).

Check notice on line 10 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · markdownlint: MD013

Line length: Expected: 80; Actual: 147 Suggested fix: Fix the markdownlint `MD013` issue at CHANGELOG.md:10: Line length: Expected: 80; Actual: 147
- Ingester scrubs occurrence message/stack/route, span names, browser context (deep) and LLM-call attributes before storage and the sink webhook; extra patterns via `AUTTER_REDACT_VALUE_PATTERNS` / `AUTTER_REDACT_KEY_PATTERNS` (`otlp-ingester`).

Check notice on line 11 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · markdownlint: MD013

Line length: Expected: 80; Actual: 245 Suggested fix: Fix the markdownlint `MD013` issue at CHANGELOG.md:11: Line length: Expected: 80; Actual: 245
- Python adapter: new stdlib-only `adapters/python/redact.py` (`redact_text`, `redact_attributes`, `configure`); the caught-exception sampler scrubs tracebacks with it.

Check notice on line 12 in CHANGELOG.md

View check run for this annotation

Autter.dev / autter/review-gate

💡 Suggestion · markdownlint: MD013

Line length: Expected: 80; Actual: 168 Suggested fix: Fix the markdownlint `MD013` issue at CHANGELOG.md:12: Line length: Expected: 80; Actual: 168
- Shared parity vectors in `test-vectors/redaction.json` run against every implementation.

### Behavior notes

- Error messages that contained secrets now fingerprint on their scrubbed form, so such issues may regroup once (they previously split per secret value).

## [1.3.1] - 2026-09-08

### Fixes
Expand Down
13 changes: 10 additions & 3 deletions adapters/python/caught_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@
ones. It has substantial overhead and must be explicitly enabled. Pass an
``emit(name, stack)`` callback that records a standard OTel exception event
with ``autter.handled=true`` and ``autter.sampled=true`` on an always-on span.

The formatted traceback includes ``str(exc)``, so it is scrubbed for secrets
and PII (connection strings, tokens, API keys, emails, …) with
:func:`redact.redact_text` before ``emit`` sees it.
"""

from __future__ import annotations

import random
import re
import sys
import threading
import time
import traceback
from collections.abc import Callable

try: # imported as part of a package
from .redact import redact_text
except ImportError: # copied next to the app as a plain module
from redact import redact_text


def install_caught_sampler(
emit: Callable[[str, str], None], *, sample_rate: float = 0.01, max_per_minute: int = 10
Expand Down Expand Up @@ -43,8 +51,7 @@ def hook(frame, event, arg):
return hook
seen.add(identity)
sent += 1
stack = "".join(traceback.format_exception(exc_type, exc_value, tb, limit=8))[:8000]
stack = re.sub(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", "[redacted]", stack, flags=re.I)
stack = redact_text("".join(traceback.format_exception(exc_type, exc_value, tb, limit=8))[:8000])
try:
emit(exc_type.__name__, stack)
except Exception:
Expand Down
198 changes: 198 additions & 0 deletions adapters/python/redact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Secret/PII scrubbing for Python services reporting to Autter Runtime.

Python exceptions routinely carry secrets in their *message* (``str(exc)``)
and therefore in every formatted traceback: ``psycopg2.OperationalError:
connection to postgres://admin:hunter2@db failed``, ``401 Incorrect API key
provided: sk-...``. Scrub before anything leaves the process:

from redact import redact_text, redact_attributes

span.add_event("exception", {
"exception.type": type(exc).__name__,
"exception.message": redact_text(str(exc)),
"exception.stacktrace": redact_text("".join(traceback.format_exception(exc))),
})
span.set_attributes(redact_attributes(custom_context))

Same patterns as ``redactText``/``redactAttributes`` in @autter/runtime-node;
parity is enforced by the shared ``test-vectors/redaction.json``. Secure by
default; extend with :func:`configure` (your own value/key patterns). The
Autter ingester scrubs again server-side as a second line of defence.

Stdlib only.
"""

from __future__ import annotations

import re
from collections.abc import Iterable, Mapping
from typing import Any

MASK = "[redacted]"

_PRIVATE_KEY_BLOCK = re.compile(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)"
)
_PREFIXED_SECRET = re.compile(
r"\b(?:sk-[A-Za-z0-9_-]{20,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}"
r"|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}"
r"|xox[abposr]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,}|(?:AKIA|ASIA)[0-9A-Z]{16}"
r"|npm_[A-Za-z0-9]{36}|autter_(?:rt|pat)_[A-Za-z0-9_-]{10,}"
r"|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})"
)
_JWT = re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")
_AUTH_SCHEME = re.compile(
r"\b(?:bearer\s+[A-Za-z0-9._~+/=-]{10,}"
r"|basic\s+(?=[A-Za-z0-9+/]*[0-9+/=])[A-Za-z0-9+/]{8,}={0,2})",
re.I,
)
_HEADER_VALUE = re.compile(
r"((?:^|[^\w-])(?:proxy-)?(?:authorization|(?:set-)?cookie)[\"']?\s*[:=]\s*[\"']?)[^\"'\r\n]+",
re.I | re.M,
)
_SECRET_ASSIGNMENT = re.compile(
r"(password|passwd|pwd|passphrase|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key"
r"|credential|signature|session[_-]?id|sessionid|ssn)"
r"([\"']?\s*[:=]\s*)"
r"(\"[^\"\r\n]*\"|'[^'\r\n]*'|[^\s\"'&,;)}\]\[<>]+)",
re.I,
)
_URL_CREDENTIALS = re.compile(
r"\b([a-z][a-z0-9+.-]{0,31}://)(?:[^\s/:@\"'<>]*:[^\s/\"'<>]*|[^\s/:@\"'<>]{16,})@", re.I
)
_CARD = re.compile(
r"\b(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d){9,15}\b"
)
_EMAIL = re.compile(r"[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}", re.I)

_SENSITIVE_KEYS = [
re.compile(p)
for p in (
r"e-?mail",
r"pass(word|wd|phrase)|^pass$|(^|[._-])pwd$",
r"token",
r"secret",
r"credential",
r"(api|access|secret|private|consumer|client|signing|encryption)-?[_.]?key",
r"authori[sz]ation|^auth(-|_|$)|bearer",
r"cookie",
r"(^|[._-])session$|^(j|php)?sess(ion)?id$|^sid$|connect\.sid",
r"phone|msisdn",
r"(^|[^a-z])ssn($|[^a-z])|social[-_ ]?security",
r"cvv|cvc|card([-_. ]?(number|num|no))?$",
r"credit[-_.]?card",
r"connection[-_.]?string|(^|[._-])dsn$",
r"recovery[-_.]?code|\botp\b|magic[-_.]?link",
)
]
# Numeric GenAI usage counters keep their value even though "token" matches.
_USAGE_KEY = re.compile(r"(^|\.)(input|output|total|prompt|completion)_?tokens$|token_count$", re.I)

_extra_values: list[re.Pattern[str]] = []
_extra_keys: list[re.Pattern[str]] = []
_enabled = True

_MAX_DEPTH = 8
_MAX_ITEMS = 1000


def configure(
*,
value_patterns: Iterable[str | re.Pattern[str]] = (),
key_patterns: Iterable[str | re.Pattern[str]] = (),
enabled: bool = True,
) -> None:
"""Add your own patterns on top of the built-ins (replaces earlier extras).

``value_patterns`` are masked wherever they occur inside strings;
``key_patterns`` mask whole values whose key matches (case-insensitive).
``enabled=False`` turns scrubbing off (not recommended).
"""
global _extra_values, _extra_keys, _enabled
_extra_values = [p if isinstance(p, re.Pattern) else re.compile(p, re.I) for p in value_patterns]
_extra_keys = [p if isinstance(p, re.Pattern) else re.compile(p, re.I) for p in key_patterns]
_enabled = enabled


def _luhn_valid(candidate: str) -> bool:
digits = [int(c) for c in candidate if c.isdigit()]
if not 13 <= len(digits) <= 19:
return False
total = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0


def _mask_assignment(match: re.Match[str]) -> str:
key, sep, value = match.group(1), match.group(2), match.group(3)
if value[:1] in ("'", '"'):
return f"{key}{sep}{value[0]}{MASK}{value[0]}"
return f"{key}{sep}{MASK}"


def redact_text(value: str) -> str:
"""Scrub secrets/PII embedded in one string; the rest survives."""
if not _enabled or not isinstance(value, str) or not value:
return value
out = _PRIVATE_KEY_BLOCK.sub(MASK, value)
out = _PREFIXED_SECRET.sub(MASK, out)
out = _JWT.sub(MASK, out)
out = _AUTH_SCHEME.sub(MASK, out)
out = _HEADER_VALUE.sub(lambda m: m.group(1) + MASK, out)
out = _SECRET_ASSIGNMENT.sub(_mask_assignment, out)
out = _URL_CREDENTIALS.sub(lambda m: m.group(1) + MASK + "@", out)
out = _CARD.sub(lambda m: MASK if _luhn_valid(m.group(0)) else m.group(0), out)
out = _EMAIL.sub(MASK, out)
for pattern in _extra_values:
out = pattern.sub(MASK, out)
return out


def is_sensitive_key(key: str) -> bool:
lowered = key.lower()
return any(p.search(lowered) for p in _SENSITIVE_KEYS) or any(p.search(key) for p in _extra_keys)


def _redact_value(key: str, value: Any, depth: int) -> Any:
if isinstance(value, bool) or value is None:
return value
if isinstance(value, (int, float)):
if _USAGE_KEY.search(key) and value >= 0:
return value
return MASK if is_sensitive_key(key) else value
if is_sensitive_key(key):
return MASK
if isinstance(value, str):
return redact_text(value)
if depth >= _MAX_DEPTH:
return MASK
if isinstance(value, Mapping):
return _redact_mapping(value, depth + 1)
if isinstance(value, (list, tuple)):
return [_redact_value("", item, depth + 1) for item in list(value)[:_MAX_ITEMS]]
return redact_text(str(value))


def _redact_mapping(mapping: Mapping[Any, Any], depth: int) -> dict[str, Any]:
out: dict[str, Any] = {}
for i, (key, value) in enumerate(mapping.items()):
if i >= _MAX_ITEMS:
out["__redaction_truncated__"] = MASK
break
out[str(key)] = _redact_value(str(key), value, depth)
return out


def redact_attributes(attributes: Mapping[str, Any] | None) -> dict[str, Any]:
"""Copy of ``attributes`` with sensitive keys masked (any depth) and
secrets scrubbed inside string values. Never mutates the input."""
if not attributes:
return {}
if not _enabled:
return dict(attributes)
return _redact_mapping(attributes, 0)
111 changes: 111 additions & 0 deletions adapters/python/test_redact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Tests for the Python adapter's redaction (stdlib unittest, no deps).

Run: python3 -m unittest discover -s adapters/python -v
"""

from __future__ import annotations

import json
import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import redact # noqa: E402
from caught_exceptions import install_caught_sampler # noqa: E402
from redact import MASK, configure, redact_attributes, redact_text # noqa: E402

VECTORS_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "test-vectors", "redaction.json")
with open(VECTORS_PATH, encoding="utf-8") as fh:
VECTORS = json.load(fh)


def _join(value):
return "".join(value) if isinstance(value, list) else value


class SharedVectors(unittest.TestCase):
def test_text_vectors(self):
for vector in VECTORS["text"]:
if "python" in vector.get("skip", []):
continue
with self.subTest(vector["name"]):
self.assertEqual(redact_text(_join(vector["input"])), _join(vector["expect"]))

def test_sensitive_keys_masked_at_any_depth(self):
for key in VECTORS["keys"]["sensitive"]:
with self.subTest(key):
out = redact_attributes({"outer": {"items": [{key: "raw"}]}})
self.assertEqual(out["outer"]["items"][0][key], MASK)

def test_safe_keys_survive(self):
for key in VECTORS["keys"]["safe"]:
with self.subTest(key):
self.assertEqual(redact_attributes({"outer": {key: "kept"}})["outer"][key], "kept")


class Attributes(unittest.TestCase):
def test_nested_values_and_usage_counters(self):
out = redact_attributes(
{
"db": {"dsn": "postgres://u:p@h/db", "args": ["redis://:pw@cache:6379"]},

Check failure on line 52 in adapters/python/test_redact.py

View check run for this annotation

Autter.dev / autter/review-gate

🔴 High · Hardcoded secret

A Postgres connection URL with credentials appears to be hard-coded in source. Rotate the value and move it to a secret manager / environment variable. Blast radius — if this is exploited it cascades to the downstream usage that depends on this file: dependent files `packages/otlp-ingester/src/normalize-otlp.ts`, `packages/otlp-ingester/src/index.ts`, `packages/otlp-ingester/src/auth.ts`, `packages/otlp-ingester/src/sink.ts`, `packages/otlp-ingester/src/normalize-browser.test.ts`, `packages/otlp-ingester/src/normalize.test.ts`, `packages/otlp-ingester/src/server.sink.test.ts`, `packages/otlp-ingester/src/sink.test.ts`. **References:** - https://docs.github.com/code-security/secret-scanning/about-secret-scanning — # Secret scanning Prevent fraudulent use of your secrets by automatically detecting exposed credentials before they can be exploited. ## In this article ## Up next Push protection - https://docs.github.com/en/code-security/reference/secret-security/supported-secret-scanning-patterns — Lists of supported secrets and the partners that GitHub works with to prevent fraudulent use of secrets that were committed accidentally. Suggested fix: In `adapters/python/test_redact.py` around line 52, what looks like a real credential is committed in source. Move it to a secret manager / env var, rotate the leaked value, and add the pattern to `.gitleaks`/`detect-secrets` so it cannot land again. If this is genuinely test data, prefix the value with `EXAMPLE_` / `YOUR_KEY` or mock the credential. Blast radius — if this is exploited it cascades to the downstream usage that depends on this file: dependent files `packages/otlp-ingester/src/normalize-otlp.ts`, `packages/otlp-ingester/src/index.ts`, `packages/otlp-ingester/src/auth.ts`, `packages/otlp-ingester/src/sink.ts`, `packages/otlp-ingester/src/normalize-browser.test.ts`, `packages/otlp-ingester/src/normalize.test.ts`, `packages/otlp-ingester/src/server.sink.test.ts`, `packages/otlp-ingester/src/sink.test.ts`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [deterministic] Hardcoded secret — Risk: 100/100

A Postgres connection URL with credentials appears to be hard-coded in source. Rotate the value and move it to a secret manager / environment variable. Blast radius — if this is exploited it cascades to the downstream usage that depends on this file: dependent files packages/otlp-ingester/src/normalize-otlp.ts, packages/otlp-ingester/src/index.ts, packages/otlp-ingester/src/auth.ts, packages/otlp-ingester/src/sink.ts, packages/otlp-ingester/src/normalize-browser.test.ts, packages/otlp-ingester/src/normalize.test.ts, packages/otlp-ingester/src/server.sink.test.ts, packages/otlp-ingester/src/sink.test.ts.

References:

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: packages/otlp-ingester/src/normalize-otlp.ts, packages/otlp-ingester/src/index.ts, packages/otlp-ingester/src/auth.ts, packages/otlp-ingester/src/sink.ts, packages/otlp-ingester/src/normalize-browser.test.ts, packages/otlp-ingester/src/normalize.test.ts, packages/otlp-ingester/src/server.sink.test.ts, packages/otlp-ingester/src/sink.test.ts
🛠 AI fix prompt (copy & paste into your coding agent)
In `adapters/python/test_redact.py` around line 52, what looks like a real credential is committed in source. Move it to a secret manager / env var, rotate the leaked value, and add the pattern to `.gitleaks`/`detect-secrets` so it cannot land again. If this is genuinely test data, prefix the value with `EXAMPLE_` / `YOUR_KEY` or mock the credential. Blast radius — if this is exploited it cascades to the downstream usage that depends on this file: dependent files `packages/otlp-ingester/src/normalize-otlp.ts`, `packages/otlp-ingester/src/index.ts`, `packages/otlp-ingester/src/auth.ts`, `packages/otlp-ingester/src/sink.ts`, `packages/otlp-ingester/src/normalize-browser.test.ts`, `packages/otlp-ingester/src/normalize.test.ts`, `packages/otlp-ingester/src/server.sink.test.ts`, `packages/otlp-ingester/src/sink.test.ts`.

Flagged by Autter security & observability checks.

"gen_ai.usage.input_tokens": 12,
"max_tokens": 100,
"note": "contact jane@example.com",
}
)
self.assertEqual(out["db"]["dsn"], MASK)
self.assertEqual(out["db"]["args"], [f"redis://{MASK}@cache:6379"])
self.assertEqual(out["gen_ai.usage.input_tokens"], 12)
self.assertEqual(out["max_tokens"], MASK)
self.assertEqual(out["note"], f"contact {MASK}")

def test_does_not_mutate_input(self):
original = {"password": "x", "nested": {"token": "y"}}
redact_attributes(original)
self.assertEqual(original, {"password": "x", "nested": {"token": "y"}})

def test_custom_patterns(self):
configure(value_patterns=[r"CUST-\d{4}"], key_patterns=[r"^tenant_ref$"])
try:
self.assertEqual(redact_text("CUST-1234 / CUST-9999"), f"{MASK} / {MASK}")
self.assertEqual(redact_attributes({"tenant_ref": "t"}), {"tenant_ref": MASK})
finally:
configure()

def test_opt_out(self):
configure(enabled=False)
try:
self.assertEqual(redact_text("password=x"), "password=x")
finally:
configure()
self.assertTrue(redact._enabled)


class CaughtExceptionSampler(unittest.TestCase):
def test_traceback_message_is_scrubbed_before_emit(self):
emitted: list[tuple[str, str]] = []

def connect():
raise ConnectionError("could not connect to postgres://admin:hunter2@db.internal:5432/app")

stop = install_caught_sampler(lambda name, stack: emitted.append((name, stack)), sample_rate=1.0)
try:
try:
connect()
except ConnectionError:
pass
finally:
stop()

self.assertTrue(emitted, "sampler emitted nothing")
name, stack = emitted[0]
self.assertEqual(name, "ConnectionError")
self.assertNotIn("hunter2", stack)
self.assertIn(f"postgres://{MASK}@db.internal:5432/app", stack)
self.assertIn("in connect", stack)


if __name__ == "__main__":
unittest.main()
Loading
Loading