phi-gate 0.2.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_gate/__init__.py +3 -0
- phi_gate/__main__.py +5 -0
- phi_gate/audit.py +330 -0
- phi_gate/cli.py +410 -0
- phi_gate/config.py +600 -0
- phi_gate/detectors.py +1086 -0
- phi_gate/globs.py +227 -0
- phi_gate/hooks.py +644 -0
- phi_gate/policy.py +97 -0
- phi_gate/py.typed +0 -0
- phi_gate/redact.py +236 -0
- phi_gate/scanner.py +776 -0
- phi_gate/shell.py +1582 -0
- phi_gate-0.2.0.dist-info/METADATA +344 -0
- phi_gate-0.2.0.dist-info/RECORD +19 -0
- phi_gate-0.2.0.dist-info/WHEEL +5 -0
- phi_gate-0.2.0.dist-info/entry_points.txt +2 -0
- phi_gate-0.2.0.dist-info/licenses/LICENSE +21 -0
- phi_gate-0.2.0.dist-info/top_level.txt +1 -0
phi_gate/__init__.py
ADDED
phi_gate/__main__.py
ADDED
phi_gate/audit.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Append-only, tamper-evident audit log.
|
|
2
|
+
|
|
3
|
+
Each event records what was decided, where, and which categories triggered
|
|
4
|
+
it. It never records matched values, hashes of values, or surrounding text:
|
|
5
|
+
a 9-digit SSN space is small enough that a hash is as good as the value, and
|
|
6
|
+
an audit trail must not become a second copy of the PHI it is protecting.
|
|
7
|
+
|
|
8
|
+
A file name can itself be PHI (`JaneDoe_labs.csv`), so the final component
|
|
9
|
+
of a target path is recorded according to `audit_targets`:
|
|
10
|
+
|
|
11
|
+
* `hash` (default): a keyed hash of the name plus its extension, such as
|
|
12
|
+
`data/#3f2a9c1d0b7e4a65.csv`. The key is the audit key when one is
|
|
13
|
+
configured, else a random salt kept beside the log (`<log>.salt`, mode
|
|
14
|
+
0600). The same file gives the same hash, so repeated events can be
|
|
15
|
+
correlated without revealing the name.
|
|
16
|
+
* `category`: the extension only, such as `data/[csv]`.
|
|
17
|
+
* `path`: the path as given. Use only where file names are known not to
|
|
18
|
+
carry identifiers.
|
|
19
|
+
|
|
20
|
+
Events form a hash chain: each carries the hash of the one before it, so
|
|
21
|
+
editing, removing or reordering a line breaks every hash after it, and
|
|
22
|
+
`phi-gate audit verify` finds where. Without a key the chain is only
|
|
23
|
+
tamper-evident against accidental damage, since anyone who can write the log
|
|
24
|
+
can recompute it. With `audit_key_file` set (in the managed policy, or in a
|
|
25
|
+
repository configuration as a path outside the project) each link is an
|
|
26
|
+
HMAC-SHA256 under that key, marked `"mac": "hmac-sha256"` with a key
|
|
27
|
+
identifier `"kid"`. Unkeyed events written before the key was introduced
|
|
28
|
+
still verify as a legacy prefix; an unkeyed event after a keyed one breaks
|
|
29
|
+
the chain. A chain cannot show that its newest lines were cut off, so a
|
|
30
|
+
deployment that needs that guarantee ships events somewhere the developer
|
|
31
|
+
cannot write as they are made.
|
|
32
|
+
|
|
33
|
+
A failure to write an event is reported on stderr. In `audit` mode the log
|
|
34
|
+
is the only control, so a failure raises AuditWriteError and the hook blocks
|
|
35
|
+
the action; a prompt override that cannot be logged is refused the same way.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
import hmac
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import secrets
|
|
45
|
+
import sys
|
|
46
|
+
import uuid
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from datetime import UTC, datetime
|
|
49
|
+
from pathlib import Path, PurePosixPath
|
|
50
|
+
|
|
51
|
+
from .config import Config, ConfigError
|
|
52
|
+
from .policy import Verdict
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import fcntl
|
|
56
|
+
except ModuleNotFoundError: # pragma: no cover - Windows
|
|
57
|
+
fcntl = None # type: ignore[assignment]
|
|
58
|
+
|
|
59
|
+
SCHEMA_VERSION = 1
|
|
60
|
+
GENESIS = "0" * 64
|
|
61
|
+
TAIL_BLOCK = 4096
|
|
62
|
+
MAC = "hmac-sha256"
|
|
63
|
+
MIN_KEY_BYTES = 16
|
|
64
|
+
MAX_EXTENSION = 8
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuditWriteError(Exception):
|
|
68
|
+
"""An event that must be recorded could not be written."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _log_path(cfg: Config) -> Path:
|
|
72
|
+
path = Path(cfg.audit_log)
|
|
73
|
+
return path if path.is_absolute() else cfg.root / path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _salt_path(cfg: Config) -> Path:
|
|
77
|
+
log = _log_path(cfg)
|
|
78
|
+
return log.with_name(log.name + ".salt")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def audit_key(cfg: Config) -> bytes | None:
|
|
82
|
+
"""The configured audit key, or None. A configured key that cannot be
|
|
83
|
+
read, or is shorter than MIN_KEY_BYTES, is a configuration error."""
|
|
84
|
+
if not cfg.audit_key_file:
|
|
85
|
+
return None
|
|
86
|
+
path = Path(cfg.audit_key_file).expanduser()
|
|
87
|
+
try:
|
|
88
|
+
key = path.read_bytes().strip()
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise ConfigError(f"cannot read audit_key_file {path}: {exc.strerror or exc}") from exc
|
|
91
|
+
if len(key) < MIN_KEY_BYTES:
|
|
92
|
+
raise ConfigError(f"audit_key_file {path} holds fewer than {MIN_KEY_BYTES} bytes")
|
|
93
|
+
return key
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def key_id(key: bytes) -> str:
|
|
97
|
+
return hashlib.sha256(b"phi-gate audit key id\0" + key).hexdigest()[:16]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _canonical(entry: dict) -> bytes:
|
|
101
|
+
body = {k: v for k, v in entry.items() if k != "hash"}
|
|
102
|
+
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _digest(entry: dict, key: bytes | None = None) -> str:
|
|
106
|
+
if key is None:
|
|
107
|
+
return hashlib.sha256(_canonical(entry)).hexdigest()
|
|
108
|
+
return hmac.new(key, _canonical(entry), hashlib.sha256).hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _last_hash(fd: int) -> str:
|
|
112
|
+
"""The hash on the file's last line, read from the end so that writing an
|
|
113
|
+
event does not get slower as the log grows."""
|
|
114
|
+
size = os.fstat(fd).st_size
|
|
115
|
+
if size == 0:
|
|
116
|
+
return GENESIS
|
|
117
|
+
tail = b""
|
|
118
|
+
position = size
|
|
119
|
+
while position > 0 and tail.count(b"\n") < 2:
|
|
120
|
+
position = max(position - TAIL_BLOCK, 0)
|
|
121
|
+
tail = os.pread(fd, min(TAIL_BLOCK, size - position), position) + tail
|
|
122
|
+
try:
|
|
123
|
+
return json.loads(tail.strip().rsplit(b"\n", 1)[-1]).get("hash", GENESIS)
|
|
124
|
+
except (ValueError, AttributeError):
|
|
125
|
+
return GENESIS # a damaged line: verification reports it
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _append(path: Path, entry: dict, key: bytes | None) -> None:
|
|
129
|
+
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
130
|
+
fd = os.open(path, flags, 0o600)
|
|
131
|
+
try:
|
|
132
|
+
# Reading the last hash and writing the next line must be one step, or
|
|
133
|
+
# two hooks firing together would fork the chain.
|
|
134
|
+
if fcntl is not None:
|
|
135
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
136
|
+
if key is not None:
|
|
137
|
+
entry["mac"] = MAC
|
|
138
|
+
entry["kid"] = key_id(key)
|
|
139
|
+
entry["prev"] = _last_hash(fd)
|
|
140
|
+
entry["hash"] = _digest(entry, key)
|
|
141
|
+
os.write(fd, (json.dumps(entry, sort_keys=True) + "\n").encode())
|
|
142
|
+
finally:
|
|
143
|
+
os.close(fd)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _salt(cfg: Config) -> bytes:
|
|
147
|
+
"""A random per-log salt for hashed targets, created on first use."""
|
|
148
|
+
path = _salt_path(cfg)
|
|
149
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
150
|
+
try:
|
|
151
|
+
fd = os.open(path, flags)
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
value = secrets.token_hex(32).encode()
|
|
154
|
+
try:
|
|
155
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
156
|
+
except FileExistsError:
|
|
157
|
+
return _salt(cfg)
|
|
158
|
+
try:
|
|
159
|
+
os.write(fd, value)
|
|
160
|
+
finally:
|
|
161
|
+
os.close(fd)
|
|
162
|
+
return value
|
|
163
|
+
try:
|
|
164
|
+
value = os.read(fd, 4096).strip()
|
|
165
|
+
finally:
|
|
166
|
+
os.close(fd)
|
|
167
|
+
if len(value) < MIN_KEY_BYTES:
|
|
168
|
+
raise OSError(f"audit salt {path} is too short")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extension(name: str) -> str:
|
|
173
|
+
suffix = PurePosixPath(name).suffix
|
|
174
|
+
return suffix if 1 < len(suffix) <= MAX_EXTENSION and suffix[1:].isalnum() else ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def describe_target(cfg: Config, target: str, key: bytes | None = None) -> str:
|
|
178
|
+
"""The form of `target` written to the log, per `audit_targets`."""
|
|
179
|
+
if cfg.audit_targets == "path" or not target:
|
|
180
|
+
return target
|
|
181
|
+
head, sep, name = target.rstrip("/").rpartition("/")
|
|
182
|
+
prefix = head + sep
|
|
183
|
+
ext = _extension(name)
|
|
184
|
+
if cfg.audit_targets == "category":
|
|
185
|
+
return f"{prefix}[{ext[1:].lower() if ext else 'file'}]"
|
|
186
|
+
try:
|
|
187
|
+
secret = key if key is not None else _salt(cfg)
|
|
188
|
+
except OSError:
|
|
189
|
+
return f"{prefix}[{ext[1:].lower() if ext else 'file'}]"
|
|
190
|
+
digest = hmac.new(secret, b"target\0" + name.encode("utf-8", "surrogatepass"), hashlib.sha256).hexdigest()
|
|
191
|
+
return f"{prefix}#{digest[:16]}{ext}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _scrub(text: str, name: str, replacement: str) -> str:
|
|
195
|
+
return text.replace(name, replacement) if name and name in text else text
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _warn(message: str) -> None:
|
|
199
|
+
try:
|
|
200
|
+
print(f"phi-gate warning: {message}", file=sys.stderr)
|
|
201
|
+
except (OSError, ValueError):
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def record(
|
|
206
|
+
cfg: Config,
|
|
207
|
+
*,
|
|
208
|
+
event: str,
|
|
209
|
+
decision: str,
|
|
210
|
+
verdict: Verdict | None = None,
|
|
211
|
+
tool: str | None = None,
|
|
212
|
+
target: str | None = None,
|
|
213
|
+
session_id: str | None = None,
|
|
214
|
+
note: str | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Append one event. Raises AuditWriteError when it cannot be written
|
|
217
|
+
and the decision depends on the log: in `audit` mode, and for a prompt
|
|
218
|
+
override. Otherwise a failure is reported on stderr."""
|
|
219
|
+
if not cfg.audit_log:
|
|
220
|
+
return
|
|
221
|
+
must_record = cfg.mode == "audit" or decision == "override"
|
|
222
|
+
try:
|
|
223
|
+
key = audit_key(cfg)
|
|
224
|
+
path = _log_path(cfg)
|
|
225
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
ignore = path.parent / ".gitignore"
|
|
227
|
+
if path.parent.name == ".phi-gate" and not ignore.exists():
|
|
228
|
+
ignore.write_text("*\n", encoding="utf-8")
|
|
229
|
+
reasons = list(verdict.reasons) if verdict else []
|
|
230
|
+
if target:
|
|
231
|
+
shown = describe_target(cfg, target, key)
|
|
232
|
+
name = target.rstrip("/").rpartition("/")[2]
|
|
233
|
+
shown_name = shown.rpartition("/")[2]
|
|
234
|
+
if shown != target and len(name) > 2:
|
|
235
|
+
note = _scrub(note, name, shown_name) if note else note
|
|
236
|
+
reasons = [_scrub(r, name, shown_name) for r in reasons]
|
|
237
|
+
target = shown
|
|
238
|
+
entry = {
|
|
239
|
+
"v": SCHEMA_VERSION,
|
|
240
|
+
"id": uuid.uuid4().hex,
|
|
241
|
+
"ts": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
242
|
+
"event": event,
|
|
243
|
+
"decision": decision,
|
|
244
|
+
"tool": tool,
|
|
245
|
+
"target": target,
|
|
246
|
+
"categories": dict(verdict.counts()) if verdict else {},
|
|
247
|
+
"reasons": reasons,
|
|
248
|
+
"session": session_id,
|
|
249
|
+
"note": note,
|
|
250
|
+
}
|
|
251
|
+
entry = {k: v for k, v in entry.items() if v not in (None, [], {})}
|
|
252
|
+
_append(path, entry, key)
|
|
253
|
+
except (OSError, ConfigError) as exc:
|
|
254
|
+
detail = exc.strerror if isinstance(exc, OSError) and exc.strerror else str(exc)
|
|
255
|
+
message = f"could not write the audit log {cfg.audit_log}: {detail}"
|
|
256
|
+
if must_record:
|
|
257
|
+
raise AuditWriteError(message) from exc
|
|
258
|
+
_warn(message)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def tail(cfg: Config, n: int = 20) -> list[dict]:
|
|
262
|
+
if not cfg.audit_log:
|
|
263
|
+
return []
|
|
264
|
+
path = _log_path(cfg)
|
|
265
|
+
if not path.is_file():
|
|
266
|
+
return []
|
|
267
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[-n:]
|
|
268
|
+
out = []
|
|
269
|
+
for ln in lines:
|
|
270
|
+
try:
|
|
271
|
+
out.append(json.loads(ln))
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
continue
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass
|
|
278
|
+
class ChainReport:
|
|
279
|
+
events: int
|
|
280
|
+
broken_line: int | None = None
|
|
281
|
+
problem: str = ""
|
|
282
|
+
keyed: int = 0
|
|
283
|
+
legacy: int = 0
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def intact(self) -> bool:
|
|
287
|
+
return self.broken_line is None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def verify(cfg: Config) -> ChainReport:
|
|
291
|
+
"""Walk the chain and report the first line that does not hold. Keyed
|
|
292
|
+
events are checked with the configured audit key; a keyed event without
|
|
293
|
+
that key, or under a different key, does not verify."""
|
|
294
|
+
key = audit_key(cfg)
|
|
295
|
+
if not cfg.audit_log or not _log_path(cfg).is_file():
|
|
296
|
+
return ChainReport(0)
|
|
297
|
+
kid = key_id(key) if key is not None else None
|
|
298
|
+
previous = GENESIS
|
|
299
|
+
events = keyed = legacy = 0
|
|
300
|
+
with _log_path(cfg).open(encoding="utf-8", errors="replace") as fh:
|
|
301
|
+
for number, line in enumerate(fh, 1):
|
|
302
|
+
try:
|
|
303
|
+
entry = json.loads(line)
|
|
304
|
+
claimed = entry["hash"]
|
|
305
|
+
linked = entry["prev"]
|
|
306
|
+
mac = entry.get("mac")
|
|
307
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
308
|
+
return ChainReport(events, number, "not a chained audit event", keyed, legacy)
|
|
309
|
+
if linked != previous:
|
|
310
|
+
return ChainReport(events, number, "does not follow the event before it", keyed, legacy)
|
|
311
|
+
if mac is None:
|
|
312
|
+
if keyed:
|
|
313
|
+
return ChainReport(events, number, "unkeyed event after keyed events", keyed, legacy)
|
|
314
|
+
if _digest(entry) != claimed:
|
|
315
|
+
return ChainReport(events, number, "content does not match its hash", keyed, legacy)
|
|
316
|
+
legacy += 1
|
|
317
|
+
elif mac == MAC:
|
|
318
|
+
if key is None:
|
|
319
|
+
return ChainReport(events, number, "keyed event, but no audit_key_file is configured",
|
|
320
|
+
keyed, legacy)
|
|
321
|
+
if entry.get("kid") != kid:
|
|
322
|
+
return ChainReport(events, number, "keyed under a different audit key", keyed, legacy)
|
|
323
|
+
if not hmac.compare_digest(_digest(entry, key), str(claimed)):
|
|
324
|
+
return ChainReport(events, number, "content does not match its keyed hash", keyed, legacy)
|
|
325
|
+
keyed += 1
|
|
326
|
+
else:
|
|
327
|
+
return ChainReport(events, number, f"unknown integrity scheme {mac!r}", keyed, legacy)
|
|
328
|
+
previous = claimed
|
|
329
|
+
events += 1
|
|
330
|
+
return ChainReport(events, keyed=keyed, legacy=legacy)
|