phi-boundary-gate 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phi_boundary_gate/__init__.py +49 -0
- phi_boundary_gate/actions.py +26 -0
- phi_boundary_gate/api.py +210 -0
- phi_boundary_gate/cli.py +127 -0
- phi_boundary_gate/compliance.py +462 -0
- phi_boundary_gate/detectors.py +306 -0
- phi_boundary_gate/policy.py +128 -0
- phi_boundary_gate/presidio_detector.py +92 -0
- phi_boundary_gate/project.py +148 -0
- phi_boundary_gate/py.typed +1 -0
- phi_boundary_gate/redacted_trace.py +70 -0
- phi_boundary_gate/report.py +247 -0
- phi_boundary_gate/sdk.py +85 -0
- phi_boundary_gate/templates/phi-compliance-policy.yml +69 -0
- phi_boundary_gate/templates/phi-policy.yml +122 -0
- phi_boundary_gate/trace.py +120 -0
- phi_boundary_gate-0.5.0.dist-info/METADATA +391 -0
- phi_boundary_gate-0.5.0.dist-info/RECORD +22 -0
- phi_boundary_gate-0.5.0.dist-info/WHEEL +5 -0
- phi_boundary_gate-0.5.0.dist-info/entry_points.txt +2 -0
- phi_boundary_gate-0.5.0.dist-info/licenses/LICENSE +21 -0
- phi_boundary_gate-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""PHI boundary gating, redaction, and audit reporting."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.5.0"
|
|
4
|
+
|
|
5
|
+
from .api import GuardDecision, GuardMode, ScanFinding, guard_text, redact_text, scan_text
|
|
6
|
+
from .compliance import (
|
|
7
|
+
ComplianceContext,
|
|
8
|
+
ComplianceDecision,
|
|
9
|
+
CompliancePolicy,
|
|
10
|
+
ServiceProfile,
|
|
11
|
+
guard_compliance,
|
|
12
|
+
load_compliance_policy,
|
|
13
|
+
)
|
|
14
|
+
from .policy import load_policy
|
|
15
|
+
from .project import ProjectConfig, check_project_config, discover_project_config, init_project, load_project_config
|
|
16
|
+
from .redacted_trace import redacted_trace_events, write_redacted_trace
|
|
17
|
+
from .report import build_report, render_markdown, write_json_report, write_markdown_report
|
|
18
|
+
from .sdk import PhiBoundaryGate
|
|
19
|
+
from .trace import TraceEvent, load_trace
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ComplianceContext",
|
|
23
|
+
"ComplianceDecision",
|
|
24
|
+
"CompliancePolicy",
|
|
25
|
+
"GuardDecision",
|
|
26
|
+
"GuardMode",
|
|
27
|
+
"PhiBoundaryGate",
|
|
28
|
+
"ProjectConfig",
|
|
29
|
+
"ScanFinding",
|
|
30
|
+
"ServiceProfile",
|
|
31
|
+
"TraceEvent",
|
|
32
|
+
"build_report",
|
|
33
|
+
"check_project_config",
|
|
34
|
+
"discover_project_config",
|
|
35
|
+
"guard_compliance",
|
|
36
|
+
"guard_text",
|
|
37
|
+
"init_project",
|
|
38
|
+
"load_compliance_policy",
|
|
39
|
+
"load_policy",
|
|
40
|
+
"load_project_config",
|
|
41
|
+
"load_trace",
|
|
42
|
+
"redact_text",
|
|
43
|
+
"redacted_trace_events",
|
|
44
|
+
"render_markdown",
|
|
45
|
+
"scan_text",
|
|
46
|
+
"write_json_report",
|
|
47
|
+
"write_markdown_report",
|
|
48
|
+
"write_redacted_trace",
|
|
49
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
DISPOSITION_RANK = {"allowed": 0, "redact": 1, "violation": 2}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def redaction_action(disposition: str) -> str:
|
|
8
|
+
if disposition == "violation":
|
|
9
|
+
return "remove_or_redact_before_this_layer"
|
|
10
|
+
if disposition == "redact":
|
|
11
|
+
return "redact_before_this_layer"
|
|
12
|
+
return "review"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def recommended_boundary_action(disposition: str, layer: str) -> str:
|
|
16
|
+
if disposition == "violation":
|
|
17
|
+
return f"Remove or redact before {layer}."
|
|
18
|
+
if disposition == "redact":
|
|
19
|
+
return f"Redact before {layer}."
|
|
20
|
+
return "Review only; no policy boundary action required."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def worst_disposition(dispositions: list[str]) -> str:
|
|
24
|
+
if not dispositions:
|
|
25
|
+
return "allowed"
|
|
26
|
+
return max(dispositions, key=lambda item: DISPOSITION_RANK[item])
|
phi_boundary_gate/api.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from .actions import redaction_action, recommended_boundary_action, worst_disposition
|
|
7
|
+
from .detectors import detect_candidates
|
|
8
|
+
from .policy import Policy
|
|
9
|
+
from .trace import SUPPORTED_LAYERS, supported_layers_text
|
|
10
|
+
|
|
11
|
+
GuardMode = Literal["report_only", "redact", "block_on_violation"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ScanFinding:
|
|
16
|
+
layer: str
|
|
17
|
+
category: str
|
|
18
|
+
value: str
|
|
19
|
+
start: int
|
|
20
|
+
end: int
|
|
21
|
+
confidence: float
|
|
22
|
+
reason: str
|
|
23
|
+
disposition: str
|
|
24
|
+
risk: str
|
|
25
|
+
rule: str
|
|
26
|
+
redaction_action: str
|
|
27
|
+
redaction: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def span(self) -> dict[str, int]:
|
|
31
|
+
return {"start": self.start, "end": self.end}
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"layer": self.layer,
|
|
36
|
+
"category": self.category,
|
|
37
|
+
"value": self.value,
|
|
38
|
+
"span": self.span,
|
|
39
|
+
"confidence": self.confidence,
|
|
40
|
+
"reason": self.reason,
|
|
41
|
+
"policy": {
|
|
42
|
+
"disposition": self.disposition,
|
|
43
|
+
"risk": self.risk,
|
|
44
|
+
"rule": self.rule,
|
|
45
|
+
},
|
|
46
|
+
"redaction": {
|
|
47
|
+
"action": self.redaction_action,
|
|
48
|
+
"suggested_value": self.redaction,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def __getitem__(self, key: str) -> Any:
|
|
53
|
+
return self.to_dict()[key]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class GuardDecision:
|
|
58
|
+
text: str
|
|
59
|
+
layer: str
|
|
60
|
+
mode: GuardMode
|
|
61
|
+
findings: list[ScanFinding]
|
|
62
|
+
redacted_text: str
|
|
63
|
+
has_phi: bool
|
|
64
|
+
has_redactions: bool
|
|
65
|
+
has_violations: bool
|
|
66
|
+
worst_disposition: str
|
|
67
|
+
recommended_action: str
|
|
68
|
+
should_block: bool
|
|
69
|
+
should_redact: bool
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"text": self.text,
|
|
74
|
+
"layer": self.layer,
|
|
75
|
+
"mode": self.mode,
|
|
76
|
+
"findings": [finding.to_dict() for finding in self.findings],
|
|
77
|
+
"redacted_text": self.redacted_text,
|
|
78
|
+
"has_phi": self.has_phi,
|
|
79
|
+
"has_redactions": self.has_redactions,
|
|
80
|
+
"has_violations": self.has_violations,
|
|
81
|
+
"worst_disposition": self.worst_disposition,
|
|
82
|
+
"recommended_action": self.recommended_action,
|
|
83
|
+
"should_block": self.should_block,
|
|
84
|
+
"should_redact": self.should_redact,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def to_safe_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"layer": self.layer,
|
|
90
|
+
"mode": self.mode,
|
|
91
|
+
"finding_count": len(self.findings),
|
|
92
|
+
"categories": sorted({finding.category for finding in self.findings}),
|
|
93
|
+
"has_phi": self.has_phi,
|
|
94
|
+
"has_redactions": self.has_redactions,
|
|
95
|
+
"has_violations": self.has_violations,
|
|
96
|
+
"worst_disposition": self.worst_disposition,
|
|
97
|
+
"recommended_action": self.recommended_action,
|
|
98
|
+
"should_block": self.should_block,
|
|
99
|
+
"should_redact": self.should_redact,
|
|
100
|
+
"redacted_text": self.redacted_text,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan_text(text: str, layer: str, policy: Policy, *, enable_presidio: bool = False) -> list[ScanFinding]:
|
|
105
|
+
if layer not in SUPPORTED_LAYERS:
|
|
106
|
+
raise ValueError(f"unsupported layer {layer!r}; expected one of: {supported_layers_text()}")
|
|
107
|
+
|
|
108
|
+
findings: list[ScanFinding] = []
|
|
109
|
+
for candidate in detect_candidates(text, enable_presidio=enable_presidio):
|
|
110
|
+
decision = policy.decide(candidate.category, layer)
|
|
111
|
+
findings.append(
|
|
112
|
+
ScanFinding(
|
|
113
|
+
layer=layer,
|
|
114
|
+
category=candidate.category,
|
|
115
|
+
value=candidate.value,
|
|
116
|
+
start=candidate.start,
|
|
117
|
+
end=candidate.end,
|
|
118
|
+
confidence=candidate.confidence,
|
|
119
|
+
reason=candidate.reason,
|
|
120
|
+
disposition=decision.disposition,
|
|
121
|
+
risk=decision.risk,
|
|
122
|
+
rule=decision.rule,
|
|
123
|
+
redaction_action=redaction_action(decision.disposition),
|
|
124
|
+
redaction=decision.redaction,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return findings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def redact_text(text: str, findings: Sequence[ScanFinding | Mapping[str, Any]]) -> str:
|
|
131
|
+
replacements = _non_overlapping_replacements(findings)
|
|
132
|
+
redacted = text
|
|
133
|
+
for start, end, replacement in reversed(replacements):
|
|
134
|
+
redacted = f"{redacted[:start]}{replacement}{redacted[end:]}"
|
|
135
|
+
return redacted
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def guard_text(
|
|
139
|
+
text: str,
|
|
140
|
+
layer: str,
|
|
141
|
+
policy: Policy,
|
|
142
|
+
mode: GuardMode = "report_only",
|
|
143
|
+
*,
|
|
144
|
+
enable_presidio: bool = False,
|
|
145
|
+
) -> GuardDecision:
|
|
146
|
+
if mode not in ("report_only", "redact", "block_on_violation"):
|
|
147
|
+
raise ValueError("mode must be one of: report_only, redact, block_on_violation")
|
|
148
|
+
|
|
149
|
+
findings = scan_text(text, layer, policy, enable_presidio=enable_presidio)
|
|
150
|
+
redacted_text = redact_text(text, findings)
|
|
151
|
+
dispositions = [finding.disposition for finding in findings]
|
|
152
|
+
worst = worst_disposition(dispositions)
|
|
153
|
+
worst_layer = layer if findings else ""
|
|
154
|
+
has_redactions = redacted_text != text
|
|
155
|
+
has_violations = any(disposition == "violation" for disposition in dispositions)
|
|
156
|
+
should_block = mode == "block_on_violation" and has_violations
|
|
157
|
+
should_redact = mode == "redact" and has_redactions
|
|
158
|
+
|
|
159
|
+
return GuardDecision(
|
|
160
|
+
text=text,
|
|
161
|
+
layer=layer,
|
|
162
|
+
mode=mode,
|
|
163
|
+
findings=findings,
|
|
164
|
+
redacted_text=redacted_text,
|
|
165
|
+
has_phi=bool(findings),
|
|
166
|
+
has_redactions=has_redactions,
|
|
167
|
+
has_violations=has_violations,
|
|
168
|
+
worst_disposition=worst,
|
|
169
|
+
recommended_action=recommended_boundary_action(worst, worst_layer) if findings else "No PHI candidates found.",
|
|
170
|
+
should_block=should_block,
|
|
171
|
+
should_redact=should_redact,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _non_overlapping_replacements(findings: Sequence[ScanFinding | Mapping[str, Any]]) -> list[tuple[int, int, str]]:
|
|
176
|
+
ordered = sorted(
|
|
177
|
+
findings,
|
|
178
|
+
key=lambda finding: (
|
|
179
|
+
_finding_start(finding),
|
|
180
|
+
-(_finding_end(finding) - _finding_start(finding)),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
replacements: list[tuple[int, int, str]] = []
|
|
184
|
+
occupied_until = -1
|
|
185
|
+
for finding in ordered:
|
|
186
|
+
start = _finding_start(finding)
|
|
187
|
+
end = _finding_end(finding)
|
|
188
|
+
if start < occupied_until:
|
|
189
|
+
continue
|
|
190
|
+
replacements.append((start, end, _finding_redaction(finding)))
|
|
191
|
+
occupied_until = end
|
|
192
|
+
return replacements
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _finding_start(finding: ScanFinding | Mapping[str, Any]) -> int:
|
|
196
|
+
if isinstance(finding, ScanFinding):
|
|
197
|
+
return finding.start
|
|
198
|
+
return int(finding["span"]["start"])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _finding_end(finding: ScanFinding | Mapping[str, Any]) -> int:
|
|
202
|
+
if isinstance(finding, ScanFinding):
|
|
203
|
+
return finding.end
|
|
204
|
+
return int(finding["span"]["end"])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _finding_redaction(finding: ScanFinding | Mapping[str, Any]) -> str:
|
|
208
|
+
if isinstance(finding, ScanFinding):
|
|
209
|
+
return finding.redaction
|
|
210
|
+
return str(finding["redaction"]["suggested_value"])
|
phi_boundary_gate/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .policy import load_policy
|
|
8
|
+
from .project import check_project_config, init_project
|
|
9
|
+
from .redacted_trace import write_redacted_trace
|
|
10
|
+
from .report import build_report, write_json_report, write_markdown_report
|
|
11
|
+
from .trace import load_trace
|
|
12
|
+
|
|
13
|
+
COMMANDS = {"init", "check-config", "scan-trace"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
18
|
+
if args_list and args_list[0] not in COMMANDS and args_list[0] not in ("-h", "--help"):
|
|
19
|
+
return _scan_trace(args_list)
|
|
20
|
+
|
|
21
|
+
parser = argparse.ArgumentParser(description="Gate PHI boundary flows and manage project policy config.")
|
|
22
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
init_parser = subparsers.add_parser("init", help="Create project policy config files.")
|
|
25
|
+
init_parser.add_argument("--root", type=Path, default=Path.cwd(), help="Project root to initialize.")
|
|
26
|
+
init_parser.add_argument("--policy", type=Path, help="Policy path relative to --root.")
|
|
27
|
+
init_parser.add_argument("--compliance-policy", type=Path, help="Compliance policy path relative to --root.")
|
|
28
|
+
init_parser.add_argument("--force", action="store_true", help="Overwrite existing generated config files.")
|
|
29
|
+
|
|
30
|
+
check_parser = subparsers.add_parser("check-config", help="Validate discovered project config and policies.")
|
|
31
|
+
check_parser.add_argument("--root", type=Path, default=Path.cwd(), help="Start directory for config discovery.")
|
|
32
|
+
|
|
33
|
+
scan_parser = subparsers.add_parser("scan-trace", help="Scan a JSONL trace and write audit outputs.")
|
|
34
|
+
_add_scan_trace_args(scan_parser)
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args(args_list)
|
|
37
|
+
if args.command == "init":
|
|
38
|
+
return _init(args)
|
|
39
|
+
if args.command == "check-config":
|
|
40
|
+
return _check_config(args)
|
|
41
|
+
return _scan_trace_from_args(args)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_scan_trace_args(parser: argparse.ArgumentParser) -> None:
|
|
45
|
+
parser.add_argument("--trace", required=True, type=Path, help="Path to a synthetic JSONL trace.")
|
|
46
|
+
parser.add_argument("--policy", required=True, type=Path, help="Path to a YAML PHI policy.")
|
|
47
|
+
parser.add_argument("--out", required=True, type=Path, help="Path for the Markdown report.")
|
|
48
|
+
parser.add_argument("--json", required=True, type=Path, dest="json_out", help="Path for the JSON report.")
|
|
49
|
+
parser.add_argument("--redacted-trace", type=Path, help="Optional path for a redacted JSONL trace.")
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--enable-presidio",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Enable optional local Presidio PII detection in addition to built-in regex rules.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _scan_trace(argv: list[str]) -> int:
|
|
58
|
+
parser = argparse.ArgumentParser(description="Gate synthetic PHI boundary flows and write audit reports.")
|
|
59
|
+
_add_scan_trace_args(parser)
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
return _scan_trace_from_args(args)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scan_trace_from_args(args: argparse.Namespace) -> int:
|
|
65
|
+
try:
|
|
66
|
+
events = load_trace(args.trace)
|
|
67
|
+
policy = load_policy(args.policy)
|
|
68
|
+
report = build_report(events, policy, args.trace, args.policy, enable_presidio=args.enable_presidio)
|
|
69
|
+
except (OSError, ValueError) as exc:
|
|
70
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
write_markdown_report(report, args.out)
|
|
74
|
+
write_json_report(report, args.json_out)
|
|
75
|
+
if args.redacted_trace:
|
|
76
|
+
try:
|
|
77
|
+
write_redacted_trace(events, policy, args.redacted_trace, enable_presidio=args.enable_presidio)
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
summary = report["summary"]
|
|
83
|
+
redacted_note = f" and redacted trace {args.redacted_trace}" if args.redacted_trace else ""
|
|
84
|
+
print(
|
|
85
|
+
"Wrote {total} PHI candidate finding(s): {markdown} and {json}{redacted}".format(
|
|
86
|
+
total=summary["total_findings"],
|
|
87
|
+
markdown=args.out,
|
|
88
|
+
json=args.json_out,
|
|
89
|
+
redacted=redacted_note,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _init(args: argparse.Namespace) -> int:
|
|
96
|
+
try:
|
|
97
|
+
config = init_project(
|
|
98
|
+
args.root,
|
|
99
|
+
policy_path=args.policy,
|
|
100
|
+
compliance_policy_path=args.compliance_policy,
|
|
101
|
+
force=args.force,
|
|
102
|
+
)
|
|
103
|
+
except (OSError, ValueError) as exc:
|
|
104
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
print(f"Wrote PHI Boundary Gate config: {config.config_path}")
|
|
107
|
+
print(f"Policy: {config.policy_path}")
|
|
108
|
+
if config.compliance_policy_path:
|
|
109
|
+
print(f"Compliance policy: {config.compliance_policy_path}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_config(args: argparse.Namespace) -> int:
|
|
114
|
+
try:
|
|
115
|
+
config = check_project_config(args.root)
|
|
116
|
+
except (OSError, ValueError) as exc:
|
|
117
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
print(f"Config ok: {config.config_path}")
|
|
120
|
+
print(f"Policy ok: {config.policy_path}")
|
|
121
|
+
if config.compliance_policy_path:
|
|
122
|
+
print(f"Compliance policy ok: {config.compliance_policy_path}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|