Skip to content

Commit be9c7b8

Browse files
committed
fix: enforce allowlist as structured output boundary
1 parent eae3f12 commit be9c7b8

20 files changed

Lines changed: 147 additions & 11 deletions

‎src/logprivacy/safe_data/normalization.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -649,10 +649,23 @@ def _process_child(
649649
category=blocking_field_rule.category,
650650
)
651651

652+
# The allowlist is an output boundary. After blocking rules have had a
653+
# chance to stop the operation, a path that is not allowed must not be
654+
# reintroduced by a mask, truncate, remove, or pseudonymize rule.
655+
allowlist_match: _AllowlistPathMatch = "exact"
656+
if self._allowlist_active:
657+
allowlist_match = self._allowlist.match_path(path)
658+
if allowlist_match == "none":
659+
counters.not_allowed += 1
660+
return _OMIT
661+
if allowlist_match == "prefix" and _is_definitely_scalar(value):
662+
counters.not_allowed += 1
663+
return _OMIT
664+
652665
path_rule = next((rule for rule in path_rules if rule.action != "block"), None)
653666
if path_rule is not None:
654667
counters.path_rule_matches += 1
655-
return self._apply_action(
668+
processed = self._apply_action(
656669
path_rule.action,
657670
value,
658671
depth=depth,
@@ -662,16 +675,11 @@ def _process_child(
662675
max_chars=path_rule.max_chars,
663676
category=path_rule.category,
664677
)
665-
666-
allowlist_match: _AllowlistPathMatch = "exact"
667-
if self._allowlist_active:
668-
allowlist_match = self._allowlist.match_path(path)
669-
if allowlist_match == "none":
670-
counters.not_allowed += 1
671-
return _OMIT
672-
if allowlist_match == "prefix" and _is_definitely_scalar(value):
673-
counters.not_allowed += 1
674-
return _OMIT
678+
return self._omit_non_container_prefix_result(
679+
processed,
680+
allowlist_match=allowlist_match,
681+
counters=counters,
682+
)
675683

676684
if field_name is not None:
677685
field_rule = next((rule for rule in field_rules if rule.action != "block"), None)
-19.2 KB
Binary file not shown.
-6.91 KB
Binary file not shown.
-7.24 KB
Binary file not shown.
-3.47 KB
Binary file not shown.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Regression tests for allowlist precedence as an output boundary."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from logprivacy import (
8+
CleanerPolicy,
9+
FieldRule,
10+
LogBlockedError,
11+
PathRule,
12+
to_safe_data,
13+
to_safe_data_with_result,
14+
)
15+
16+
17+
def test_allowlist_omits_path_even_when_path_rule_would_mask() -> None:
18+
policy = (
19+
CleanerPolicy.default()
20+
.allow_paths("status")
21+
.add_path_rules(PathRule.exact("account.balance", action="mask"))
22+
)
23+
24+
result = to_safe_data_with_result(
25+
{
26+
"status": "ok",
27+
"account": {"balance": "10000"},
28+
},
29+
policy=policy,
30+
)
31+
32+
assert result.cleaned == {"status": "ok"}
33+
assert result.stats.not_allowed == 1
34+
assert result.stats.path_rule_matches == 0
35+
36+
37+
def test_allowlist_omits_path_even_when_path_rule_would_remove() -> None:
38+
policy = (
39+
CleanerPolicy.default()
40+
.allow_paths("status")
41+
.add_path_rules(PathRule.exact("debug", action="remove"))
42+
)
43+
44+
safe = to_safe_data(
45+
{
46+
"status": "ok",
47+
"debug": "internal details",
48+
},
49+
policy=policy,
50+
)
51+
52+
assert safe == {"status": "ok"}
53+
assert "debug" not in safe
54+
55+
56+
def test_allowlisted_path_still_uses_path_rule() -> None:
57+
policy = (
58+
CleanerPolicy.default()
59+
.allow_paths("account.balance")
60+
.add_path_rules(PathRule.exact("account.balance", action="mask"))
61+
)
62+
63+
result = to_safe_data_with_result(
64+
{
65+
"account": {
66+
"balance": "10000",
67+
"currency": "USD",
68+
}
69+
},
70+
policy=policy,
71+
)
72+
73+
assert result.cleaned == {"account": {"balance": "[SECRET]"}}
74+
assert result.stats.path_rule_matches == 1
75+
assert result.stats.not_allowed == 1
76+
77+
78+
def test_parent_prefix_cannot_be_reintroduced_as_masked_scalar() -> None:
79+
policy = (
80+
CleanerPolicy.default()
81+
.allow_paths("account.balance")
82+
.add_path_rules(PathRule.exact("account", action="mask"))
83+
)
84+
85+
result = to_safe_data_with_result(
86+
{"account": {"balance": "10000"}},
87+
policy=policy,
88+
)
89+
90+
assert result.cleaned == {}
91+
assert result.stats.not_allowed == 1
92+
93+
94+
def test_allowlist_runs_before_non_blocking_field_rule() -> None:
95+
policy = (
96+
CleanerPolicy.default()
97+
.allow_paths("status")
98+
.add_field_rules(FieldRule.exact("debug", action="mask"))
99+
)
100+
101+
result = to_safe_data_with_result(
102+
{
103+
"status": "ok",
104+
"debug": "internal details",
105+
},
106+
policy=policy,
107+
)
108+
109+
assert result.cleaned == {"status": "ok"}
110+
assert result.stats.field_rule_matches == 0
111+
assert result.stats.not_allowed == 1
112+
113+
114+
def test_block_still_precedes_allowlist() -> None:
115+
policy = (
116+
CleanerPolicy.default()
117+
.allow_paths("status")
118+
.add_path_rules(PathRule.exact("debug", action="block"))
119+
)
120+
121+
with pytest.raises(LogBlockedError):
122+
to_safe_data(
123+
{
124+
"status": "ok",
125+
"debug": "internal details",
126+
},
127+
policy=policy,
128+
)
-169 Bytes
Binary file not shown.
Binary file not shown.
-164 Bytes
Binary file not shown.
-3.14 KB
Binary file not shown.

0 commit comments

Comments
 (0)