Skip to content

Commit b257dec

Browse files
committed
feat: add path rules, allowlists, JSONL streaming, and HMAC pseudonymization
1 parent db3fa2d commit b257dec

27 files changed

Lines changed: 2778 additions & 68 deletions

‎CHANGELOG.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## 0.5.3 - Unreleased
6+
7+
### Added
8+
9+
- `PathRule` — path-based privacy rules matching full traversal paths such as
10+
`"account.balance"` or `"orders.*.order_id"` (exact and glob modes). Takes
11+
precedence over `FieldRule`, `sensitive_keys`, and the allowlist.
12+
- `HMACMaskingStrategy` — deterministic pseudonymization via HMAC-SHA256.
13+
Key must be `bytes`; it is never stored in `repr`, exceptions, or
14+
serialization. Token format: `[CATEGORY:hmac:hexdigest]`.
15+
- `pseudonymize` action for `FieldRule` and `PathRule`; requires
16+
`policy.with_pseudonymizer(HMACMaskingStrategy(key=...))`.
17+
- `CleanerPolicy.allow_paths(*paths)` — opt-in allowlist: any field whose path
18+
is not covered (directly or as a parent) is removed. Parent nodes leading to
19+
allowed fields are preserved. Values of allowed fields are still sanitized.
20+
- `CleanerPolicy.add_path_rules()`, `with_path_rules()`, `with_pseudonymizer()`.
21+
- `CleanerPolicy.from_dict()`, `to_dict()`, `from_json()`, `to_json()` for
22+
schema-version-1 declarative policies. Keys for HMAC are never serialized.
23+
- JSONL streaming: `safe_jsonl_write()`, `iter_safe_jsonl()`, `clean_jsonl()`,
24+
`scan_jsonl()`. `clean_jsonl` writes atomically via a temp file and
25+
`os.replace`; the original file is never partially overwritten on failure.
26+
- `JSONLRecord`, `JSONLResult`, `JSONLStats`, `JSONLScanRecord` result types.
27+
- `PolicyConfigurationError`, `JSONLProcessingError`,
28+
`PseudonymizationConfigurationError` error types.
29+
- `SafeDataStats` fields: `path_rule_matches`, `not_allowed`, `pseudonymized`.
30+
31+
### Changed
32+
33+
- Allowlist removal (`not_allowed`) does NOT set `complete=False`; it is an
34+
intentional policy decision, not a traversal failure.
35+
- Structured traversal precedence is now explicit:
36+
`block` → `PathRule` → `FieldRule` → `sensitive_keys` → `allowlist` →
37+
`text sanitization`.
38+
39+
### Security
40+
41+
- HMAC key is excluded from `repr`, `str`, `to_dict`, and `to_json`.
42+
- `clean_jsonl` uses atomic rename; a failed write leaves the original intact.
43+
- Allowlist defaults to inactive — adding one is an explicit opt-in.
44+
545
## 0.5.2 - Unreleased
646

747
### Fixed

‎docs/data/structured-data.md‎

Lines changed: 192 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,25 +157,78 @@ underscores are treated as the same separator.
157157
Explicit field rules are evaluated in declaration order. If no field rule
158158
matches, 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
181234
from 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

193307
Use `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

228345
Both `SafeDataResult` and `SafeDataStats` are frozen dataclasses. Neither retains
229346
any 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.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "logprivacy"
7-
version = "0.5.2"
7+
version = "0.5.3"
88
description = "Zero-dependency privacy-first log redaction for Python."
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎src/logprivacy/__init__.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,37 @@
2323
from logprivacy.audit import AuditReport
2424
from logprivacy.cleaner import Cleaner
2525
from logprivacy.exceptions import (
26+
JSONLProcessingError,
2627
LogBlockedError,
2728
LogPrivacyAssertionError,
2829
LogPrivacyError,
30+
PolicyConfigurationError,
31+
PseudonymizationConfigurationError,
2932
RuleValidationError,
3033
)
3134
from logprivacy.field_rules import FieldAction, FieldRule
3235
from logprivacy.integrations.logging_filter import LogPrivacyFilter
3336
from logprivacy.integrations.logging_formatter import LogPrivacyFormatter
3437
from logprivacy.json import safe_json_dump, safe_json_dumps
38+
from logprivacy.jsonl import clean_jsonl, iter_safe_jsonl, safe_jsonl_write, scan_jsonl
3539
from logprivacy.masking.strategy import (
3640
HashMaskingStrategy,
41+
HMACMaskingStrategy,
3742
PartialMaskingStrategy,
3843
PlaceholderMaskingStrategy,
3944
)
45+
from logprivacy.path_rules import PathAction, PathRule
4046
from logprivacy.policy import CleanerPolicy
41-
from logprivacy.result import Finding, RedactionResult, SafeDataResult, SafeDataStats
47+
from logprivacy.result import (
48+
Finding,
49+
JSONLRecord,
50+
JSONLResult,
51+
JSONLScanRecord,
52+
JSONLStats,
53+
RedactionResult,
54+
SafeDataResult,
55+
SafeDataStats,
56+
)
4257
from logprivacy.rules.custom import CustomRegexRule
4358
from logprivacy.rules.email import EmailRule
4459
from logprivacy.rules.secret import SecretRule
@@ -57,7 +72,13 @@
5772
"FieldAction",
5873
"FieldRule",
5974
"Finding",
75+
"HMACMaskingStrategy",
6076
"HashMaskingStrategy",
77+
"JSONLProcessingError",
78+
"JSONLRecord",
79+
"JSONLResult",
80+
"JSONLScanRecord",
81+
"JSONLStats",
6182
"JSONScalar",
6283
"JSONValue",
6384
"LogBlockedError",
@@ -66,7 +87,11 @@
6687
"LogPrivacyFilter",
6788
"LogPrivacyFormatter",
6889
"PartialMaskingStrategy",
90+
"PathAction",
91+
"PathRule",
6992
"PlaceholderMaskingStrategy",
93+
"PolicyConfigurationError",
94+
"PseudonymizationConfigurationError",
7095
"RedactionResult",
7196
"RuleValidationError",
7297
"SafeDataResult",
@@ -78,17 +103,21 @@
78103
"audit",
79104
"clean",
80105
"clean_file",
106+
"clean_jsonl",
81107
"clean_text",
82108
"clean_url",
83109
"clean_with_result",
84110
"explain",
85111
"get_safe_logger",
112+
"iter_safe_jsonl",
113+
"safe_jsonl_write",
86114
"safe_print",
87115
"safe_json_dump",
88116
"safe_json_dumps",
89117
"scan_file",
118+
"scan_jsonl",
90119
"to_safe_data",
91120
"to_safe_data_with_result",
92121
]
93122

94-
__version__ = "0.5.2"
123+
__version__ = "0.5.3"

0 commit comments

Comments
 (0)