@@ -157,25 +157,78 @@ underscores are treated as the same separator.
157157Explicit field rules are evaluated in declaration order. If no field rule
158158matches, legacy ` sensitive_keys ` masking is applied.
159159
160+ ## Path Rules
161+
162+ ` PathRule ` matches the full traversal path, not just the field name. This lets
163+ you target a field in a specific context:
164+
165+ ``` python
166+ from logprivacy import CleanerPolicy, PathRule, to_safe_data
167+
168+ # "balance" inside "account" → remove; "balance" elsewhere → untouched
169+ policy = CleanerPolicy.default().add_path_rules(
170+ PathRule.exact(" account.balance" , action = " remove" ),
171+ )
172+
173+ to_safe_data(
174+ {" strategy" : {" balance" : 1000 }, " account" : {" balance" : 25000 }},
175+ policy = policy,
176+ )
177+ # {"strategy": {"balance": 1000}, "account": {"balance": "[REMOVED]"}}
178+ ```
179+
180+ Use ` PathRule.glob ` with a ` * ` wildcard to match any single segment:
181+
182+ ``` python
183+ policy = CleanerPolicy.default().add_path_rules(
184+ PathRule.glob(" orders.*.order_id" , action = " mask" ),
185+ )
186+ ```
187+
188+ ** Precedence:** ` PathRule ` takes priority over ` FieldRule ` , ` sensitive_keys ` ,
189+ and the allowlist. The full precedence order is:
190+ ` block ` → ` PathRule ` → ` FieldRule ` → ` sensitive_keys ` → ` allowlist ` →
191+ ` text sanitization ` .
192+
193+ ** Supported actions:** ` mask ` , ` remove ` , ` truncate ` , ` block ` , ` pseudonymize ` .
194+
195+ ### Path Syntax
196+
197+ - Segments are separated by ` . ` .
198+ - ` * ` matches exactly one segment in glob mode (not allowed in exact mode).
199+ - ` ** ` and partial wildcards (` q* ` , ` *.json ` ) are not supported.
200+ - Empty segments, leading/trailing dots, and double dots are rejected.
201+
202+ ``` python
203+ PathRule.exact(" account.balance" ) # exact two-segment path
204+ PathRule.glob(" orders.*.quantity" ) # any order's quantity field
205+ PathRule.glob(" events.*.metadata.token" ) # deeper nesting
206+ ```
207+
208+ Field name segments use the same normalization as ` FieldRule ` : camelCase is
209+ split, separators are unified, case is folded. List indices match as their
210+ string representation (` "0" ` , ` "1" ` ), or via ` * ` in glob mode.
211+
160212## Actions
161213
162- Field rules support four actions:
214+ Both ` FieldRule ` and ` PathRule ` support these actions:
163215
164216- ` mask ` : replace the value using the policy masking strategy;
165- - ` remove ` : keep the field but replace the value with ` [REMOVED] ` ;
217+ - ` remove ` : replace the value with ` [REMOVED] ` (key is kept) ;
166218- ` truncate ` : sanitize the ** full** text first, then cut to ` max_chars ` and
167- append ` [TRUNCATED] ` if needed. This order ensures secrets that fall after
168- the cut point are never exposed in the output .
219+ append ` [TRUNCATED] ` if needed. This order ensures secrets after the cut
220+ point are never exposed.
169221- ` block ` : raise ` LogBlockedError ` without including the blocked value.
222+ - ` pseudonymize ` : replace the value with a deterministic HMAC token (requires
223+ ` policy.with_pseudonymizer(HMACMaskingStrategy(key=...)) ` ).
170224
171225` truncate ` is limited to textual values (` str ` , ` bytes ` , ` bytearray ` , and
172- ` memoryview ` ). Non-text values become ` [TRUNCATED] ` without sanitization
173- (there is no text content to redact).
226+ ` memoryview ` ). Non-text values become ` [TRUNCATED] ` .
174227
175228### Field Rules on Exceptions
176229
177- Field rules apply to the ` type ` and ` message ` fields produced when an exception
178- is sanitized, giving the same protection as mappings and dataclasses :
230+ Field rules (and path rules) apply to the ` type ` and ` message ` fields produced
231+ when an exception is sanitized :
179232
180233``` python
181234from logprivacy import CleanerPolicy, FieldRule, to_safe_data
@@ -188,6 +241,67 @@ to_safe_data(ValueError("secret token: abc123"), policy=policy)
188241# {"type": "ValueError", "message": "[REMOVED]"}
189242```
190243
244+ ## Pseudonymization
245+
246+ ` HMACMaskingStrategy ` replaces identifiers with deterministic correlation
247+ tokens. The same key + value + category always produces the same token; rotate
248+ the key to invalidate all existing tokens.
249+
250+ ** This is pseudonymization, not anonymization.** A party with the key can
251+ re-derive any token from the original value.
252+
253+ ``` python
254+ from logprivacy import CleanerPolicy, HMACMaskingStrategy, PathRule, to_safe_data
255+
256+ key = b " your-secret-key-at-least-16-bytes"
257+ policy = (
258+ CleanerPolicy.default()
259+ .add_path_rules(PathRule.glob(" orders.*.order_id" , action = " pseudonymize" , category = " order_id" ))
260+ .with_pseudonymizer(HMACMaskingStrategy(key = key))
261+ )
262+
263+ to_safe_data({" orders" : [{" order_id" : " ORD-1234" , " amount" : 99.0 }]}, policy = policy)
264+ # {"orders": [{"order_id": "[ORDER_ID:hmac:a3b2c1d4e5f6]", "amount": 99.0}]}
265+ ```
266+
267+ The HMAC key never appears in ` repr ` , ` str ` , exceptions, ` to_dict ` , or ` to_json ` .
268+
269+ ## Allowlist
270+
271+ When an allowlist is configured, any field whose path is not in the allowlist
272+ (and does not lead to an allowed field) is removed from the output. Values of
273+ allowed fields are still sanitized.
274+
275+ ``` python
276+ from logprivacy import CleanerPolicy, to_safe_data
277+
278+ policy = CleanerPolicy.default().allow_paths(
279+ " event_type" ,
280+ " timestamp" ,
281+ " error.type" , # "error" parent is preserved automatically
282+ )
283+
284+ to_safe_data(
285+ {" event_type" : " login" , " timestamp" : " 2026-01-01" , " error" : {" type" : " ValueError" , " message" : " secret" }},
286+ policy = policy,
287+ )
288+ # {"event_type": "login", "timestamp": "2026-01-01", "error": {"type": "ValueError"}}
289+ ```
290+
291+ The allowlist supports ` * ` wildcards for single-segment matching:
292+
293+ ``` python
294+ policy = CleanerPolicy.default().allow_paths(" orders.*.status" )
295+ ```
296+
297+ ** Precedence:** PathRule and FieldRule actions still apply to allowed fields.
298+ A ` block ` PathRule or FieldRule on an allowed field raises ` LogBlockedError ` .
299+ Allowlist removal does ** not** set ` result.complete = False ` ; it is an
300+ intentional policy decision, not a traversal failure.
301+
302+ The ` stats.not_allowed ` counter tracks how many fields were removed by the
303+ allowlist.
304+
191305## Rich Output with SafeDataResult
192306
193307Use ` to_safe_data_with_result() ` when you need to know whether sanitization was
@@ -224,6 +338,9 @@ log_event(result.cleaned)
224338| ` unsupported ` | values replaced with ` [UNSUPPORTED:TypeName] ` |
225339| ` adapter_errors ` | converters that raised, returned self, or had ` __instancecheck__ ` raise during resolution |
226340| ` field_rule_matches ` | total ` FieldRule ` matches across all fields |
341+ | ` path_rule_matches ` | total ` PathRule ` matches across all fields |
342+ | ` not_allowed ` | fields removed by the allowlist |
343+ | ` pseudonymized ` | fields pseudonymized with ` HMACMaskingStrategy ` |
227344
228345Both ` SafeDataResult ` and ` SafeDataStats ` are frozen dataclasses. Neither retains
229346any reference to the original value or raw converter output.
@@ -272,8 +389,72 @@ safe_json_dumps(event, policy=policy)
272389# '{"job_id": "job-42", "raw_payload": "password=[SECRET] status=failed", ...}'
273390```
274391
392+ ## Safe JSONL
393+
394+ Process JSON Lines files without loading the whole file into memory:
395+
396+ ``` python
397+ from pathlib import Path
398+ from logprivacy.jsonl import safe_jsonl_write, iter_safe_jsonl, clean_jsonl, scan_jsonl
399+
400+ # Write one sanitized record
401+ with open (" out.jsonl" , " w" ) as f:
402+ safe_jsonl_write({" email" : " user@example.com" , " action" : " login" }, f)
403+
404+ # Read and sanitize a JSONL file line by line
405+ for record in iter_safe_jsonl(Path(" events.jsonl" ), on_error = " skip" ):
406+ print (record.line_number, record.result.cleaned)
407+
408+ # Clean a JSONL file atomically (temp file → os.replace)
409+ result = clean_jsonl(Path(" raw.jsonl" ), output = Path(" clean.jsonl" ))
410+ print (result.stats.lines_written)
411+
412+ # Scan for sensitive data without modifying
413+ for scan_record in scan_jsonl(Path(" events.jsonl" )):
414+ print (scan_record.line_number, scan_record.findings)
415+ ```
416+
417+ ` on_error ` controls how invalid JSON lines are handled:
418+ - ` "raise" ` — raise ` JSONLProcessingError ` (never includes the raw line);
419+ - ` "skip" ` — silently omit the bad line;
420+ - ` "placeholder" ` — emit ` {"_logprivacy_error": "invalid_json", "_line": N} ` .
421+
422+ ` clean_jsonl ` writes atomically: a temp file is created in the same directory,
423+ then ` os.replace ` performs an atomic rename. If any step fails, the temp file
424+ is removed and the original is untouched.
425+
426+ ## Declarative Policies
427+
428+ Policies can be serialized to and from JSON for configuration files or sharing
429+ across services:
430+
431+ ``` python
432+ from logprivacy import CleanerPolicy
433+
434+ policy = CleanerPolicy.from_json("""
435+ {
436+ "schema_version": 1,
437+ "base": "strict",
438+ "field_rules": [{"match": "password", "mode": "exact", "action": "mask"}],
439+ "path_rules": [{"path": "account.balance", "mode": "exact", "action": "remove"}],
440+ "allowlist": {"paths": ["timestamp", "event_type"]}
441+ }
442+ """ )
443+
444+ # Export back
445+ config_json = policy.to_json(sort_keys = True )
446+ ```
447+
448+ HMAC keys are ** never** serialized. A policy that uses ` pseudonymize ` records
449+ only the action and category; the key must be injected separately via
450+ ` policy.with_pseudonymizer(HMACMaskingStrategy(key=...)) ` .
451+
452+ Schema version 1 supports: ` base ` , ` field_rules ` , ` path_rules ` , ` allowlist ` .
453+ Unknown fields, unknown bases, unknown actions, and unknown modes are rejected
454+ with a ` PolicyConfigurationError ` .
455+
275456## Limitations
276457
277- This phase does not implement JSONPath rules, JSONL helpers, Pydantic, attrs,
278- Structlog, Loguru, OpenTelemetry, rule packs , or framework -specific behavior.
279- Adapters are explicit and local to the registry you pass.
458+ Not implemented: JSONPath, ` ** ` wildcards, YAML, Pydantic, attrs, Structlog ,
459+ Loguru, OpenTelemetry, plugins , or domain -specific rule packs. Adapters are
460+ explicit and local to the registry you pass.
0 commit comments