Keep operational visibility without exporting credentials, personal data, or unnecessary request details
Application and reverse-proxy HTTP logs can quietly collect authorization headers, session identifiers, query-string secrets, client addresses, and application-specific identifiers. This guide builds a fail-closed Python pipeline that bounds input reads, allowlists output fields, pseudonymizes selected identifiers, canonicalizes timestamps, tests for leakage, and terminates when its exporter disconnects. It also covers production key delivery, process supervision, backpressure, restart behavior, permissions, and bounded diagnostics. Localtonet appears separately as the connectivity layer for exposing the protected local web service, not as the source or processor of these logs.
๐ What's in this guide
Why application and reverse-proxy HTTP logs need data minimization
Exposing a local HTTP service creates a legitimate need for observability. We may need to know whether requests succeed, which routes are slow, whether the service returns repeated server errors, and whether traffic volume changes unexpectedly. The mistake is assuming that complete request data is necessary to answer those questions.
Applications, frameworks, web servers, and reverse proxies can all produce HTTP access logs. Depending on their configuration, a record may contain a timestamp, HTTP method, host, raw path, query string, response status, response size, processing duration, client address, user agent, referrer, headers, account identifiers, and correlation identifiers. Custom application logs may also contain request or response bodies. Some values are useful operational signals. Others are credentials or personal data, and many are unnecessary for the intended analysis.
Query strings deserve particular attention. URLs frequently carry search terms, email addresses, document identifiers, password-reset tokens, signed download parameters, OAuth authorization data, invitation codes, or application keys. If a logger records the raw request target, those values can propagate into local files, collectors, export queues, dashboards, alerts, support tickets, and long-term archives.
Headers present a similar risk. An Authorization header can contain a bearer token or another credential. Cookies can contain session identifiers. Custom headers may contain API keys, signed assertions, internal tenant identifiers, or user information. Removing only fields whose names contain words such as password or token is not a sufficient control.
A Localtonet HTTP tunnel makes the configured local web service reachable through a public HTTPS address. It does not remove the need for application authentication, authorization, input validation, least-privilege access, or safe session handling. Do not put credentials in URLs, and do not rely on log redaction to compensate for missing access controls.
Sanitizing records before export reduces how widely sensitive information can spread. A remote destination cannot accidentally index or retain a value that was never sent to it. A narrow schema also makes dashboards more consistent and can reduce downstream ingestion and storage volume.
This article deliberately distinguishes two systems. The redaction pipeline belongs to the application, reverse proxy, or local collector. Localtonet provides connectivity to the local service. We are not claiming that a Localtonet HTTP tunnel produces or transforms the application and reverse-proxy logs used by this example. The filtering workflow runs under your control before records are sent to an analytics platform, SIEM, or storage destination.
Design a privacy-aware logging boundary
Start by drawing a clear boundary around the device or private environment that runs the service. Raw events should remain inside that boundary long enough to be parsed and sanitized. Only the transformed representation should enter the exporter or leave for a remote destination.
The intended logical flow is:
Application or reverse proxy
|
v
Local structured HTTP log source
|
v
Bounded reader, parser, and schema validator
|
v
Allowlist, redact, pseudonymize, and reshape
|
+---- rejected record ----> bounded safe diagnostic and counter
|
v
Sanitized output stream or queue
|
v
Remote analytics, SIEM, or storage
Exporter disconnects
|
v
Transformer exits nonzero and supervision policy takes over
Do not connect the raw source directly to the remote exporter with the transformer operating as an optional side branch. If the transformer stops, a permissive fallback could send unmodified records. Put transformation in the required path and define what happens when parsing, validation, output, or delivery fails.
Start from operational questions
List the questions the logs must answer. Examples include: How many requests reached each route? What proportion returned 500-level responses? How long did requests take? Which application environment handled them? Did a particular pseudonymous user encounter repeated failures?
Then define the smallest schema capable of answering those questions. If a dashboard needs a route classification, status code, and duration, it usually does not need cookies, request bodies, complete URLs, email addresses, or an exact client IP address.
Prefer routing metadata over raw paths
A route template such as /users/{id} is generally safer and more useful for aggregation than a raw path such as /users/482913. The application framework often knows the matched route after routing has completed. Capture that trusted routing value directly when possible.
A regular expression cannot establish that a string came from the routing layer. It can reject forbidden syntax and impose a length limit, but a raw identifier-bearing path such as /users/482913 can still match the same permitted character set. In the implementation below, route trust therefore comes from source provenance and application configuration, not from SAFE_ROUTE.
If the source cannot supply trusted routing metadata, omitting the route can be safer than exporting the raw path. Application-specific normalization is another option, but it must be designed against the real routing table and tested. A generic sanitizer cannot reliably decide whether a path segment is a public category, account number, document identifier, or secret download token.
Decide what to keep, transform, or remove
A written field policy keeps logging behavior reviewable. It should identify the purpose of every exported field, its transformation, its retention expectations, and who can access it. The policy should cover nested objects because sensitive data often hides inside a generic request, context, or metadata structure.
| Input data | Recommended default | Reason |
|---|---|---|
| Timestamp | Convert to one canonical UTC format | Supports sequencing, alert windows, and investigations without mixed offsets or representations. |
| HTTP method | Keep from an allowed set | Useful for traffic analysis without normally identifying a user. |
| Trusted matched route | Keep when supplied by the routing layer | Supports aggregation while avoiding identifiers embedded in raw paths. |
| Raw path and query string | Remove by default | May contain account IDs, search terms, tokens, signed parameters, or personal data. |
| Status code and duration | Keep after type and range validation | Supports reliability and performance monitoring. |
| Authorization, cookie, and API-key headers | Remove completely | These values can grant access and should not enter an observability destination. |
| Request and response bodies | Do not collect by default | Bodies can contain credentials, private messages, uploaded content, and regulated data. |
| Client IP address | Omit, reduce precision, or pseudonymize | The appropriate treatment depends on the operational purpose and applicable policy. |
| User or tenant identifier | Use keyed pseudonymization only when correlation is required | Preserves controlled grouping without exporting the original identifier. |
| User agent | Omit or classify locally | The complete string can be highly specific; a coarse client category may be sufficient. |
| Error message | Export an approved error code or class | Free-form exceptions can contain request values, file paths, SQL fragments, or credentials. |
| Correlation identifier | Keep only if generated safely | It should be opaque, bounded, and unrelated to a credential or account identifier. |
Why an allowlist is stronger than a denylist
A denylist begins with the complete input and removes known dangerous fields. It can fail when developers add a header, nest a value under another object, change a field name, or begin logging a body. The new data is exported until somebody notices and updates the rules.
An allowlist creates a fresh record containing only approved fields. Unknown source fields are dropped automatically. Schema evolution becomes safer because a newly introduced source field does not cross the export boundary without a deliberate code and policy change.
Redaction and pseudonymization solve different problems
Redaction removes or replaces a value so it cannot be recovered from the exported record. This is appropriate for credentials and information with no valid analytics purpose. Pseudonymization transforms a stable identifier into another stable value so records can still be correlated. It reduces direct exposure but does not make the records anonymous.
When correlation is justified, use a keyed construction such as HMAC instead of a plain hash. Predictable identifiers, including email addresses and sequential account numbers, can often be guessed and compared against an unkeyed digest. Reproducing an HMAC requires possession of the secret key.
A stable digest can link a person's activity across events. Protect transformed logs with destination permissions and retention limits. Keep the HMAC key separate from exported data, restrict access to it, and plan rotation according to your correlation and retention requirements.
Build a fail-closed local HTTP log transformer
The reference implementation uses Python 3.11 or newer and only the Python standard library. It reads newline-delimited JSON from standard input and emits sanitized newline-delimited JSON to standard output. It is not a Localtonet command or feature. Adapt its input mapping to the structured format emitted by your application or reverse proxy.
The program retains a canonical UTC timestamp, method, trusted route value, status, duration, service name, environment, an optional request ID, a reduced-precision IPv4 network, and an optional HMAC pseudonym. It never copies headers, cookies, raw URLs, query strings, bodies, free-form errors, or unknown metadata.
A 64 KiB record limit is enforced while reading. The program calls readline() with a maximum allocation of the limit plus one byte, then drains an oversized record in bounded chunks. It does not first allocate an arbitrarily long line and check its length afterward.
Make the source emit structured records
Configure the application or reverse proxy to emit one JSON object per line. Prefer a matched route value produced by the application routing layer. Do not enable request-body, response-body, cookie, authorization-header, or full-query logging merely to feed this pipeline.
Define the exported schema
Document each output field and the operational question it supports. Treat additions as reviewed policy changes rather than automatically retaining everything available at the source.
Create the transformer
Save the program below as sanitize_logs.py. The configuration names belong only to this sample and are not Localtonet environment variables.
Place it in the required export path
Feed raw local records into the program and connect only its standard output to the exporter. Keep standard error local and access-controlled. Do not configure a fallback that forwards raw records.
Test normal and failure paths
Test malformed JSON, oversized input, timestamp offsets, weak configuration, schema drift, broken output pipes, and exact output keys before enabling remote delivery.
#!/usr/bin/env python3
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
import stat
import sys
from datetime import datetime, timezone
ALLOWED_METHODS = {
"GET", "HEAD", "POST", "PUT", "PATCH",
"DELETE", "OPTIONS"
}
SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
SAFE_ROUTE = re.compile(r"^/[A-Za-z0-9_./{}:-]{0,255}$")
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
MAX_INPUT_BYTES = 64 * 1024
MIN_HASH_KEY_BYTES = 32
MAX_KEY_FILE_BYTES = 1024
SOFTWARE_EXIT = 70
CONFIG_EXIT = 78
OUTPUT_EXIT = 74
class ConfigurationError(Exception):
pass
class DiagnosticLimiter:
def __init__(self, stream, initial=10, interval=1000):
self.stream = stream
self.initial = initial
self.interval = interval
self.total = 0
def rejected(self, line_number, category):
self.total += 1
if self.total <= self.initial or self.total % self.interval == 0:
print(
f"rejected log record {line_number}: {category}; "
f"total_rejected={self.total}",
file=self.stream,
flush=True
)
def decode_hash_key(encoded):
if not isinstance(encoded, str) or not encoded:
raise ConfigurationError("HMAC key is missing")
try:
key = base64.b64decode(
encoded.encode("ascii"),
altchars=b"-_",
validate=True
)
except (UnicodeEncodeError, binascii.Error) as exc:
raise ConfigurationError(
"HMAC key must be valid base64"
) from exc
if len(key) < MIN_HASH_KEY_BYTES:
raise ConfigurationError(
f"HMAC key must decode to at least "
f"{MIN_HASH_KEY_BYTES} bytes"
)
return key
def read_key_file(path):
flags = os.O_RDONLY
if hasattr(os, "O_CLOEXEC"):
flags |= os.O_CLOEXEC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ConfigurationError("cannot open HMAC key file") from exc
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise ConfigurationError(
"HMAC key file must be a regular file"
)
if os.name == "posix" and stat.S_IMODE(metadata.st_mode) & 0o077:
raise ConfigurationError(
"HMAC key file must not be accessible "
"by group or other users"
)
with os.fdopen(descriptor, "rb", closefd=True) as key_file:
descriptor = -1
data = key_file.read(MAX_KEY_FILE_BYTES + 1)
if len(data) > MAX_KEY_FILE_BYTES:
raise ConfigurationError("HMAC key file is too large")
try:
encoded = data.decode("ascii").strip()
except UnicodeDecodeError as exc:
raise ConfigurationError(
"HMAC key file must contain base64 text"
) from exc
return decode_hash_key(encoded)
finally:
if descriptor != -1:
os.close(descriptor)
def load_hash_key(environ=None):
environ = os.environ if environ is None else environ
inline_value = environ.get("LOG_HASH_KEY_B64")
file_path = environ.get("LOG_HASH_KEY_FILE")
if bool(inline_value) == bool(file_path):
raise ConfigurationError(
"set exactly one of LOG_HASH_KEY_B64 "
"or LOG_HASH_KEY_FILE"
)
if file_path:
return read_key_file(file_path)
return decode_hash_key(inline_value)
def normalized_timestamp(value):
if not isinstance(value, str) or len(value) > 64:
raise ValueError("invalid timestamp")
candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
try:
parsed = datetime.fromisoformat(candidate)
except ValueError as exc:
raise ValueError("invalid timestamp") from exc
if parsed.tzinfo is None:
raise ValueError("timestamp must include a timezone")
utc_value = parsed.astimezone(timezone.utc)
return utc_value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
def bounded_integer(value, minimum, maximum, field):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer")
if value < minimum or value > maximum:
raise ValueError(f"{field} is outside the accepted range")
return value
def safe_name(value, field):
if not isinstance(value, str) or not SAFE_NAME.fullmatch(value):
raise ValueError(f"invalid {field}")
return value
def safe_route(value):
if not isinstance(value, str) or not SAFE_ROUTE.fullmatch(value):
raise ValueError("invalid route value")
if "?" in value or "#" in value:
raise ValueError("route contains a query or fragment")
return value
def safe_request_id(value):
if not isinstance(value, str) or not SAFE_REQUEST_ID.fullmatch(value):
raise ValueError("invalid request ID")
return value
def pseudonymize(value, hash_key):
if not isinstance(value, str) or not value or len(value) > 256:
raise ValueError("invalid identifier")
digest = hmac.new(
hash_key,
value.encode("utf-8"),
hashlib.sha256
).hexdigest()
return digest[:32]
def reduce_ipv4_precision(value):
if value is None:
return None
if not isinstance(value, str):
raise ValueError("invalid client address")
parts = value.split(".")
if len(parts) != 4:
return None
try:
octets = [int(part) for part in parts]
except ValueError:
return None
if any(octet < 0 or octet > 255 for octet in octets):
return None
return f"{octets[0]}.{octets[1]}.{octets[2]}.0"
def sanitize(event, hash_key):
if not isinstance(event, dict):
raise ValueError("event must be an object")
method = event.get("method")
if not isinstance(method, str):
raise ValueError("method must be a string")
method = method.upper()
if method not in ALLOWED_METHODS:
raise ValueError("method is not allowed")
output = {
"timestamp": normalized_timestamp(event.get("timestamp")),
"method": method,
"route": safe_route(event.get("route_template")),
"status": bounded_integer(
event.get("status"), 100, 599, "status"
),
"duration_ms": bounded_integer(
event.get("duration_ms"),
0,
86_400_000,
"duration_ms"
),
"service": safe_name(event.get("service"), "service"),
"environment": safe_name(
event.get("environment"),
"environment"
)
}
request_id = event.get("request_id")
if request_id is not None:
output["request_id"] = safe_request_id(request_id)
client_network = reduce_ipv4_precision(event.get("client_ip"))
if client_network is not None:
output["client_network"] = client_network
user_id = event.get("user_id")
if user_id is not None:
output["user_pseudonym"] = pseudonymize(
user_id,
hash_key
)
output["server_error"] = output["status"] >= 500
return output
def drain_oversized_record(stream):
while True:
chunk = stream.readline(MAX_INPUT_BYTES + 1)
if not chunk or chunk.endswith(b"\n"):
return
def read_bounded_record(stream):
data = stream.readline(MAX_INPUT_BYTES + 1)
if not data:
return None, False
if len(data) > MAX_INPUT_BYTES:
if not data.endswith(b"\n"):
drain_oversized_record(stream)
return None, True
return data, False
def run(input_stream, output_stream, error_stream, hash_key):
diagnostics = DiagnosticLimiter(error_stream)
line_number = 0
while True:
raw_line, oversized = read_bounded_record(input_stream)
if raw_line is None and not oversized:
return 0
line_number += 1
if oversized:
diagnostics.rejected(line_number, "oversized")
continue
try:
event = json.loads(raw_line)
output = sanitize(event, hash_key)
except (
json.JSONDecodeError,
UnicodeDecodeError,
ValueError,
TypeError
):
diagnostics.rejected(line_number, "invalid")
continue
encoded = (
json.dumps(
output,
separators=(",", ":"),
ensure_ascii=True,
sort_keys=True
) + "\n"
).encode("ascii")
try:
output_stream.write(encoded)
output_stream.flush()
except (BrokenPipeError, OSError):
print(
"fatal: sanitized export stream failed",
file=error_stream,
flush=True
)
return OUTPUT_EXIT
def main():
try:
hash_key = load_hash_key()
except ConfigurationError:
print(
"fatal: invalid HMAC key configuration",
file=sys.stderr,
flush=True
)
return CONFIG_EXIT
try:
return run(
sys.stdin.buffer,
sys.stdout.buffer,
sys.stderr,
hash_key
)
except Exception:
print(
"fatal: unexpected transformer failure",
file=sys.stderr,
flush=True
)
return SOFTWARE_EXIT
if __name__ == "__main__":
raise SystemExit(main())
Parsing and validation errors are handled only around json.loads() and sanitize(). Output failures are handled separately. A closed pipe, failed write, or failed flush causes exit status 74 instead of being mislabeled as a malformed record. This prevents the process from continuing to consume and discard input after its export path has disappeared.
Diagnostics include only a record number, a broad category, and an aggregate rejection count. The first ten rejections are reported, followed by every thousandth rejection. This rate limit prevents malformed traffic from creating an unbounded secondary log stream. The program does not print raw input, exception text, environment values, or failed output payloads.
The canonical timestamp output is always UTC with six fractional-second digits and a trailing Z. Inputs such as 2026-08-20T12:15:30+02:00 and 2026-08-20T10:15:30Z therefore produce the same representation when they identify the same instant.
SAFE_ROUTE rejects unsupported characters, query strings, fragments, and excessive length. It does not prove that a value is a route template. For example, /users/482913 passes the character check. Configure the application to populate route_template from trusted post-routing metadata rather than copying the raw request path into that field.
Generate and deliver the HMAC key
Production deployments require a cryptographically random key of at least 32 bytes. Do not use a memorable phrase, account password, repository variable copied between projects, or arbitrary text value. Generate the key using a cryptographically secure random source and deliver it through the deployment's secret-management mechanism.
The following command is suitable for generating a new base64-encoded 32-byte key file on a trusted administration host with Python 3.11 or newer:
umask 077
python3 -c 'import base64,secrets; print(base64.b64encode(secrets.token_bytes(32)).decode("ascii"))' > log-hmac-key.b64
chmod 600 log-hmac-key.b64
Move the secret into the approved secret store or protected credential mechanism for your environment. In production, point LOG_HASH_KEY_FILE at a file materialized for the transformer process and readable only by its service account. The sample rejects a POSIX key file with group or other permission bits. Alternatively, a secret manager may inject LOG_HASH_KEY_B64 directly into the process environment, but the visibility and lifecycle of environment-based secrets depend on the operating system and supervisor.
Do not place the production key in source control, shell history, container image layers, general configuration bundles, exported diagnostics, or tickets. Key rotation changes pseudonyms. Decide whether breaking correlation between rotation periods is intended, and retain old keys only when a documented operational need justifies doing so.
Run a synthetic development check
The commands in this subsection are for an isolated development test. They are not a production supervision design. The example values are synthetic, the IP address belongs to a documentation range, and the credential-looking strings are intentionally fake.
{"timestamp":"2026-08-20T12:15:30+02:00","method":"post","route_template":"/api/orders/{id}","raw_url":"/api/orders/8472?access_token=FAKE-SECRET-123","status":201,"duration_ms":84,"service":"orders-api","environment":"development","request_id":"req_test_001","client_ip":"192.0.2.44","user_id":"customer-8472","headers":{"authorization":"Bearer FAKE-TOKEN","cookie":"session=FAKE-SESSION"},"body":{"card":"FAKE-CARD-DATA"}}
Save the record as sample.ndjson. Generate a disposable test key and run the transformer:
export LOG_HASH_KEY_B64="$(python3 -c 'import base64,secrets; print(base64.b64encode(secrets.token_bytes(32)).decode("ascii"))')"
python3 sanitize_logs.py < sample.ndjson > sanitized.ndjson
test "$?" -eq 0
Inspect sanitized.ndjson. It should contain only the approved fields constructed by sanitize(). The raw URL, query value, headers, body, original user ID, complete client address, and unknown nested objects must be absent. The timestamp should be 2026-08-20T10:15:30.000000Z.
Development redirection is useful for a controlled test, but production needs an explicit supervisor, service identity, restart policy, resource limits, protected secret delivery, destination behavior, and monitoring. Validate those controls against the operating system, collector, and exporter you actually deploy.
Test leakage, limits, timestamps, configuration, and output failure
A transformer is a security control and should be tested as one. Happy-path tests prove only that ordinary records work. Leakage tests must place recognizable fake values in every location where application code or middleware could add sensitive data.
Save the following as test_sanitize_logs.py next to the transformer. It checks sensitive-field removal, exact output keys, offset normalization, query rejection, the limits of route validation, oversized input, invalid JSON, weak and missing key configuration, and a disconnected output stream.
import base64
import io
import json
import secrets
import unittest
import sanitize_logs
TEST_KEY = secrets.token_bytes(32)
def valid_event():
return {
"timestamp": "2026-08-20T12:15:30+02:00",
"method": "POST",
"route_template": "/api/orders/{id}",
"status": 201,
"duration_ms": 84,
"service": "orders-api",
"environment": "test",
"request_id": "req_test_001",
"client_ip": "192.0.2.44",
"user_id": "customer-8472",
"raw_url": (
"/api/orders/8472?"
"token=FAKE-QUERY-CANARY"
),
"headers": {
"authorization": "Bearer FAKE-AUTH-CANARY",
"cookie": "session=FAKE-COOKIE-CANARY"
},
"body": "FAKE-BODY-CANARY",
"error": "FAKE-ERROR-CANARY",
"new_sensitive_field": "FAKE-DRIFT-CANARY"
}
class BrokenOutput:
def write(self, data):
raise BrokenPipeError("synthetic closed pipe")
def flush(self):
pass
class SanitizerTests(unittest.TestCase):
def test_sensitive_fields_are_not_exported(self):
output = sanitize_logs.sanitize(
valid_event(),
TEST_KEY
)
serialized = json.dumps(output)
for marker in (
"FAKE-QUERY-CANARY",
"FAKE-AUTH-CANARY",
"FAKE-COOKIE-CANARY",
"FAKE-BODY-CANARY",
"FAKE-ERROR-CANARY",
"FAKE-DRIFT-CANARY",
"customer-8472",
"192.0.2.44"
):
self.assertNotIn(marker, serialized)
def test_exact_output_keys(self):
output = sanitize_logs.sanitize(
valid_event(),
TEST_KEY
)
self.assertEqual(
set(output),
{
"timestamp",
"method",
"route",
"status",
"duration_ms",
"service",
"environment",
"request_id",
"client_network",
"user_pseudonym",
"server_error"
}
)
def test_timestamp_offsets_become_canonical_utc(self):
event = valid_event()
output = sanitize_logs.sanitize(event, TEST_KEY)
self.assertEqual(
output["timestamp"],
"2026-08-20T10:15:30.000000Z"
)
event["timestamp"] = "2026-08-20T10:15:30Z"
equivalent = sanitize_logs.sanitize(
event,
TEST_KEY
)
self.assertEqual(
output["timestamp"],
equivalent["timestamp"]
)
def test_query_string_is_rejected(self):
event = valid_event()
event["route_template"] = (
"/reset?token=FAKE-TOKEN"
)
with self.assertRaises(ValueError):
sanitize_logs.sanitize(event, TEST_KEY)
def test_matching_path_is_not_proof_of_template_trust(self):
event = valid_event()
event["route_template"] = "/users/482913"
output = sanitize_logs.sanitize(
event,
TEST_KEY
)
self.assertEqual(output["route"], "/users/482913")
def test_invalid_json_is_rejected_without_output(self):
input_stream = io.BytesIO(
b'{"incomplete":true\n'
)
output_stream = io.BytesIO()
error_stream = io.StringIO()
status = sanitize_logs.run(
input_stream,
output_stream,
error_stream,
TEST_KEY
)
self.assertEqual(status, 0)
self.assertEqual(output_stream.getvalue(), b"")
self.assertIn("invalid", error_stream.getvalue())
def test_oversized_record_is_drained_and_next_record_runs(self):
oversized = (
b'{"value":"'
+ b"x" * sanitize_logs.MAX_INPUT_BYTES
+ b'"}\n'
)
accepted = (
json.dumps(valid_event()).encode("utf-8")
+ b"\n"
)
input_stream = io.BytesIO(oversized + accepted)
output_stream = io.BytesIO()
error_stream = io.StringIO()
status = sanitize_logs.run(
input_stream,
output_stream,
error_stream,
TEST_KEY
)
self.assertEqual(status, 0)
self.assertEqual(
len(output_stream.getvalue().splitlines()),
1
)
self.assertIn("oversized", error_stream.getvalue())
def test_missing_key_configuration_is_rejected(self):
with self.assertRaises(
sanitize_logs.ConfigurationError
):
sanitize_logs.load_hash_key({})
def test_weak_key_configuration_is_rejected(self):
weak = base64.b64encode(b"too-short").decode("ascii")
with self.assertRaises(
sanitize_logs.ConfigurationError
):
sanitize_logs.load_hash_key({
"LOG_HASH_KEY_B64": weak
})
def test_both_key_sources_are_rejected(self):
encoded = base64.b64encode(
secrets.token_bytes(32)
).decode("ascii")
with self.assertRaises(
sanitize_logs.ConfigurationError
):
sanitize_logs.load_hash_key({
"LOG_HASH_KEY_B64": encoded,
"LOG_HASH_KEY_FILE": "/not/read"
})
def test_broken_output_pipe_returns_nonzero(self):
input_stream = io.BytesIO(
json.dumps(valid_event()).encode("utf-8")
+ b"\n"
)
error_stream = io.StringIO()
status = sanitize_logs.run(
input_stream,
BrokenOutput(),
error_stream,
TEST_KEY
)
self.assertEqual(
status,
sanitize_logs.OUTPUT_EXIT
)
self.assertIn(
"sanitized export stream failed",
error_stream.getvalue()
)
if __name__ == "__main__":
unittest.main()
Run the suite with Python 3.11 or newer:
python3 -m unittest -v test_sanitize_logs.py
The test named test_matching_path_is_not_proof_of_template_trust is intentional. It demonstrates that the syntax check accepts /users/482913. The test does not endorse exporting that raw path. It documents why the application routing layer, rather than the regular expression, must establish the value's provenance.
Use canary values across every possible source field
Build fixtures containing unique fake markers such as FAKE-AUTH-CANARY-91B7 and FAKE-COOKIE-CANARY-42D1. Place them in headers, cookies, query values, paths, bodies, user IDs, error messages, nested metadata, and unknown fields. Search the complete sanitized output and all local diagnostics for every marker.
Test prohibited field names as well. Even when a fixture's value does not match a canary, the presence of keys such as authorization, cookie, raw_url, query, or body reveals a schema regression. Exact output-key assertions are stronger than checking only that several known bad keys are missing.
Test the entire path
Unit tests protect transformation logic, but production behavior also depends on process wiring. Run an end-to-end test from the actual local source through the collector, transformer, queue, and a non-production destination. Verify metadata added by the collector and exporter. Some agents attach source filenames, host names, process environments, tags, or failed payloads outside the transformed JSON object.
Capture a sanitized sample from the final destination and compare its keys against the approved schema. Test what happens when the destination pauses, rejects credentials, closes its input, runs out of space, or returns an error. Confirm that raw records are not copied into dead-letter storage, support notifications, or general diagnostics.
Operate the redaction pipeline safely
Correct transformation logic can still fail operationally. The process might crash, the source schema might change, the exporter might become unavailable, or malformed input might arrive continuously. Safe operation requires explicit decisions for backpressure, restart, permissions, diagnostics, rejected records, and uncertain delivery.
Fail closed for remote export
If parsing or validation fails, do not forward the original record as a fallback. Increment a rejection counter and emit only a bounded safe diagnostic. If an investigation genuinely requires retaining rejected input, place it in an optional restricted local quarantine with short retention and tightly controlled access. Quarantine must not become a permanent second archive.
If writing sanitized output fails, terminate nonzero. Continuing to read would discard records without delivering them, and treating a broken pipe as an invalid event would hide the operational failure. A supervisor should distinguish a nonzero transformer exit from an ordinary rejected record and alert or restart according to the deployment's documented policy.
Plan for backpressure
The sample performs blocking writes and flushes each accepted record. If the downstream consumer slows while the pipe remains open, the write eventually blocks and the transformer stops consuming new input. That is backpressure. It is safer than silently dropping an unbounded number of records, but it transfers pressure toward the source.
Decide where bounded buffering belongs. A durable local queue can preserve sanitized records during a temporary destination outage, but it needs capacity limits, filesystem permissions, retention rules, disk monitoring, and defined behavior when full. An in-memory queue avoids writing additional data to disk but loses queued records on restart. The right choice depends on the delivery guarantees and failure budget of the surrounding system.
| Failure | Required behavior | Operational consideration |
|---|---|---|
| Malformed or invalid input | Reject locally and continue | Count and rate-limit diagnostics without printing raw content. |
| Oversized record | Drain it in bounded chunks and reject it | Do not allocate the complete record or treat its remaining chunks as new events. |
| Slow destination | Apply backpressure or use a bounded sanitized queue | Monitor queue depth, processing delay, disk use, and source behavior. |
| Closed export pipe | Exit nonzero | Do not continue consuming and dropping input. |
| Transformer restart | Resume according to source checkpoint semantics | Duplicates or gaps are possible unless the surrounding collector defines acknowledgment behavior. |
| Invalid key configuration | Refuse to start | Never fall back to plain hashing, an empty key, or raw identifiers. |
Define restart and delivery semantics
Restarting the transformer is not enough to guarantee that every record is delivered exactly once. A pipe-based producer may already have considered a record consumed when the transformer fails. A file tailer or queue may replay unacknowledged records. An exporter may have accepted a record immediately before its connection closed, causing a retry to create a duplicate.
Document where acknowledgment occurs and whether the system favors replay, loss avoidance, or bounded dropping. If deduplication is required, use an approved opaque event identifier. Do not repurpose a credential, user identifier, complete URL, or sensitive request value as the deduplication key.
Configure the supervisor to run the process under a dedicated unprivileged service account, start it only after required local dependencies and secrets are available, restart it after nonzero failure with bounded delay, and prevent a rapid crash loop. Alert when restarts exceed the expected threshold. Do not configure a restart wrapper that switches to an unsanitized path when repeated failures occur.
Restrict files, processes, and diagnostics
The transformer should receive only the permissions required to read its local source, read its key, and write sanitized output. It should not need administrative access to the application, broad access to unrelated logs, or write permission to source files. Key files should be owned by the service account or an appropriate privileged owner and inaccessible to group and other users.
Raw local log files remain sensitive even when export is sanitized. Keep their retention as short as the workflow permits, restrict filesystem access, avoid copying them into broad backups by default, and manage temporary files deliberately. If the application can emit a minimal structured event directly, that is preferable to creating a highly detailed raw record and cleaning it later.
Keep standard error local unless a trusted collector is explicitly configured to accept only the transformer's bounded diagnostic format. Monitor accepted count, rejected count, oversized count, process exits, restart count, output queue depth, destination failures, and active policy version. These aggregate signals do not require raw request content.
Review and version schema changes
Keep the sanitizer and tests in version control. Require review for additions to the output schema, especially fields derived from URLs, identifiers, headers, free-form messages, or nested objects. Record why a field is necessary and when that need should be reassessed.
Deploy changes to a non-production stream first. Compare acceptance and rejection counts, examine only approved sanitized samples, and confirm downstream dashboards continue to work. If rollback is required, return to a previously reviewed policy rather than bypassing transformation.
Combine transformation with application access controls, process isolation, destination permissions, retention limits, secure key handling, change review, monitoring, and incident procedures. The appropriate fields and retention periods depend on the service, its users, and applicable organizational and legal requirements.
Expose the protected local service with a Localtonet HTTP tunnel
Once the web service is running locally and its logging pipeline is operating safely, Localtonet can provide remote HTTP access without inbound router port forwarding, firewall changes, VPN setup, or a public IP address. Our client on the device establishes an outbound connection to a Localtonet relay server. The running tunnel supplies a public HTTPS address for the configured local target.
This connectivity step does not replace the sanitizer. Requests reach the application through the tunnel, while the application or reverse proxy continues to create and process its own logs locally. Keep transformation between the local log source and any remote observability destination.
The maintained HTTP tunnel workflow follows this sequence. Dashboard availability and selectable server values can vary, so use the values shown in the current product rather than copying a server code from an old guide.
Install and run the Localtonet client
Run our client on the device that hosts the HTTP service or on a device that can reach it over the local network. Confirm that the application is already running and locally reachable.
Open the HTTP tunnel configuration
In the Localtonet dashboard, go to the HTTP tunnel area and create the HTTP configuration for the service you want to expose.
Select the process type
Choose the available HTTP process type appropriate to the public address you intend to use. HTTP tunnels can serve the same content through a generated subdomain, a selected subdomain where supported, or a custom domain. Check current DNS instructions before configuring a custom domain.
Select the AuthToken and server
Select the device-specific authentication token for the connected client, then choose an available relay server from the dashboard. Treat the token as a secret and do not place it in logs, screenshots, source control, or tickets.
Enter the local IP address and port
Point the tunnel to the verified local address and port where the web service listens. The selected client device must be able to reach that target. Do not target a raw-log directory, collector administration port, quarantine location, or exporter control interface.
Start and verify the tunnel
Use the Start action after saving the configuration. Creating a tunnel does not mean it is running. Test the assigned public URL with an authorized request, and remember that the address is available only while the tunnel is running and the selected client is connected.
Review the current Localtonet HTTP tunnel documentation alongside the dashboard before applying custom-domain or environment-specific settings. Stop or delete the tunnel when remote access is no longer required.
The Localtonet HTTP tunnel should point to the web service that authorized users need to reach. Do not expose raw-log files, a local quarantine, collector administration interfaces, secret-management endpoints, or exporter controls through the same public address.
Verify the combined workflow
Send a controlled request through the public tunnel using a test account and harmless canary. Confirm that the application returns the expected response. Then check the transformed output for the approved route, method, status, duration, and service fields.
Confirm that the complete request URL, query values, credentials, cookies, original account identifier, full client address, body, and unknown fields were not exported. Test an authentication failure and an application error because exceptional code paths often use different logging statements.
Stop the tunnel and verify that the public endpoint is no longer available. Stopping connectivity does not delete records already stored locally or remotely, and it does not necessarily stop a separate exporter. Tunnel lifecycle, application lifecycle, and log-export lifecycle are independent controls.
Troubleshoot common redaction and export failures
The transformer rejects every record
Compare the actual structured source schema with the expected fields. Check timestamp timezone information, integer types, method values, routing metadata, and field names. Do not solve the problem by copying unknown input wholesale. Update the explicit mapping and tests after reviewing the new schema.
Raw identifiers still appear in the route
The source is probably placing a raw path into route_template. The regular expression cannot detect this reliably. Modify application-level logging to emit the matched route from the routing layer after routing completes. If the framework cannot provide one, define application-specific normalization backed by the routing table or omit the route.
The process stops when the exporter disconnects
That is the intended fail-closed behavior. A broken output pipe is not a malformed record. Confirm the nonzero status is visible to the supervisor, investigate the exporter or queue, and restore the sanitized delivery path. Do not restart into a configuration that discards output or forwards raw input.
The transformer stalls during a destination outage
Blocking may be backpressure from the downstream consumer. Inspect the sanitized queue, pipe consumer, destination connection, disk capacity, and processing latency. Decide whether to pause the source, retain a bounded sanitized backlog, or reject new records according to the deployment's documented failure policy.
Diagnostics are too sparse during a rejection spike
The sample deliberately rate-limits per-record messages. Use aggregate counters and a controlled test fixture to investigate the schema change. Do not disable rate limiting or print rejected raw records into a general log destination. If more detail is necessary, add bounded category codes that cannot contain source data.
Pseudonyms change unexpectedly
Stable HMAC output requires the same normalized input and key. Check for key rotation, whitespace, case differences, prefixes, encoding changes, or identifier-type changes. Define canonicalization carefully. Combining distinct identifiers through overly aggressive normalization can be as harmful as losing correlation.
The destination receives duplicates after restart
A connection can fail after the destination accepted a record but before the producer observed successful completion. A replaying source may then send it again. Use an approved opaque event identifier when deduplication is required, and verify the destination's behavior instead of assuming exactly-once delivery.
The public URL works but no records are exported
Treat service connectivity and observability as separate paths. First verify that the application or reverse proxy writes a local structured event. Then inspect transformer acceptance counts, bounded rejection diagnostics, process status, queue health, exporter connectivity, and destination ingestion. A functioning Localtonet tunnel proves that the web service is reachable, not that a separate analytics destination is healthy.
The local service works but the tunnel does not
Confirm that the selected Localtonet client is connected, the tunnel has been started, and the target IP address and port match the locally verified service. Ensure the client device can reach that target. If the application enforces a host policy or application-level authentication, configure it deliberately rather than disabling security controls.
Frequently asked questions
Does Localtonet redact application or reverse-proxy logs automatically?
This guide does not make that claim. The transformer runs locally under your control and processes records created by your application, web server, or reverse proxy. The Localtonet HTTP tunnel is a separate connectivity layer for the configured local web service.
Why does the transformer exit when its output pipe closes?
Continuing to read after export fails could consume and silently drop input. The reference implementation treats write and flush failures as fatal, emits a bounded diagnostic, and exits nonzero so supervision and monitoring can respond.
Does the route regular expression prove that a value is a route template?
No. It validates characters and length, and it rejects queries and fragments. A raw path such as /users/482913 still matches. Trust must come from application routing metadata or another reviewed application-specific source.
Should authorization headers and cookies be masked or removed?
Remove their values from exported operational logs. Partial masking can leave useful credential fragments or encourage downstream users to depend on sensitive fields. Export an approved authentication outcome or credential category if that signal is operationally necessary.
Is hashing a user identifier enough to make logs anonymous?
No. A stable pseudonym can still link activity across events. Plain hashes are also vulnerable when identifiers are predictable. Use keyed HMAC only when correlation is justified, protect the key, restrict access to the records, and continue treating them as sensitive.
What Python version does the reference implementation require?
The supported minimum for this tutorial is Python 3.11. Run both the transformer and its tests with Python 3.11 or newer, and pin the runtime version in production so upgrades are reviewed and repeatable.
Can I safely keep a complete URL after removing known secret parameters?
A parameter denylist is fragile because applications add new names and sensitive values can appear in path segments. Prefer trusted routing metadata and omit the query string. If selected query information is necessary, allowlist specific inputs and export only a validated classification or reduced value.
What should happen when a record cannot be transformed?
Reject it from remote export, increment a safe aggregate metric, and emit a bounded diagnostic without raw content. If investigation requires retaining the event, use a restricted local quarantine with short retention. Never forward the original record automatically.
Does stopping a Localtonet tunnel stop log export?
Not necessarily. The tunnel and logging pipeline are separate processes. Stopping the tunnel ends public access through that tunnel, but the application may continue running and its exporter may continue sending queued or locally generated records.
Expose your protected local service with Localtonet
Build, test, and supervise the local HTTP log-redaction boundary first. Then use our HTTP tunnel to provide remote connectivity to the authorized web service without inbound router port forwarding or a public IP address.
Get Started Free โ