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/scanner.py ADDED
@@ -0,0 +1,776 @@
1
+ """Scan text, files, directories and git changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ import codecs
7
+ import gzip
8
+ import html
9
+ import os
10
+ import re
11
+ import sqlite3
12
+ import stat
13
+ import subprocess
14
+ import tempfile
15
+ import zipfile
16
+ from collections import Counter
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .config import Config
22
+ from .detectors import LOW, MEDIUM, Finding, run_detectors
23
+ from .policy import ALLOW, BLOCK, WARN, Verdict, evaluate
24
+
25
+ SNIFF = 4096
26
+ MAX_DIR_FILES = 300
27
+ MAX_UNTRACKED = 300
28
+
29
+ # Formats phi-gate cannot read without third-party libraries. Files with these
30
+ # signatures are reported as "not inspected" (see README, Limitations).
31
+ OPAQUE_MAGIC = (
32
+ b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"SQLite format 3", b"\xd0\xcf\x11\xe0",
33
+ b"\x1f\x8b", b"PAR1", b"II*\x00", b"MM\x00*", b"RIFF", b"7z\xbc\xaf", b"Rar!",
34
+ )
35
+ SKIP_DIRS = {
36
+ ".git", "node_modules", ".venv", "venv", "__pycache__", ".next", "dist", "build", ".phi-gate", ".svelte-kit",
37
+ ".mypy_cache", ".ruff_cache", ".pytest_cache", ".hypothesis", ".tox",
38
+ }
39
+
40
+
41
+ def _line_index(text: str) -> list[int]:
42
+ starts = [0]
43
+ for m in re.finditer("\r\n|\r|\n", text):
44
+ starts.append(m.end())
45
+ return starts
46
+
47
+
48
+ def _locate(findings: list[Finding], text: str, line_map: list[int] | None = None) -> list[Finding]:
49
+ starts = _line_index(text)
50
+ out = []
51
+ for f in findings:
52
+ idx = bisect.bisect_right(starts, f.start) - 1
53
+ line = line_map[idx] if line_map and idx < len(line_map) else idx + 1
54
+ out.append(Finding(f.category, f.severity, f.group, f.start, f.end, line, f.start - starts[idx] + 1))
55
+ return out
56
+
57
+
58
+ def _findings(text: str, cfg: Config, line_map: list[int] | None = None) -> list[Finding]:
59
+ findings = run_detectors(text, frozenset(cfg.disable))
60
+ if cfg.allow_values:
61
+ findings = [f for f in findings if not cfg.is_allowed_value(text[f.start:f.end])]
62
+ return _locate(findings, text, line_map)
63
+
64
+
65
+ def scan_text(text: str, cfg: Config, line_map: list[int] | None = None) -> Verdict:
66
+ return evaluate(_findings(text, cfg, line_map), cfg.record_window)
67
+
68
+
69
+ @dataclass
70
+ class FileResult:
71
+ path: str
72
+ verdict: Verdict
73
+ note: str = ""
74
+ skipped: bool = False
75
+
76
+ @property
77
+ def blocking(self) -> bool:
78
+ return self.verdict.blocking
79
+
80
+
81
+ # --------------------------------------------------------------------------
82
+ # Decoding
83
+ # --------------------------------------------------------------------------
84
+
85
+
86
+ def _utf16_guess(head: bytes) -> str | None:
87
+ if len(head) < 64:
88
+ return None
89
+ even = head[0::2]
90
+ odd = head[1::2]
91
+ if odd.count(0) > 0.4 * len(odd) and even.count(0) < 0.05 * len(even):
92
+ return "utf-16-le"
93
+ if even.count(0) > 0.4 * len(even) and odd.count(0) < 0.05 * len(odd):
94
+ return "utf-16-be"
95
+ return None
96
+
97
+
98
+ def _looks_binary(head: bytes) -> bool:
99
+ if not head:
100
+ return False
101
+ control = sum(1 for b in head if b < 9 or 13 < b < 32 and b != 27)
102
+ return control > 0.10 * len(head)
103
+
104
+
105
+ def decode_bytes(raw: bytes) -> str | None:
106
+ """Decode text in UTF-8/16/32 (BOM or heuristics). None means binary."""
107
+ for bom, enc in (
108
+ (codecs.BOM_UTF32_LE, "utf-32"), (codecs.BOM_UTF32_BE, "utf-32"),
109
+ (codecs.BOM_UTF8, "utf-8-sig"), (codecs.BOM_UTF16_LE, "utf-16"), (codecs.BOM_UTF16_BE, "utf-16"),
110
+ ):
111
+ if raw.startswith(bom):
112
+ return raw.decode(enc, errors="replace")
113
+ guessed = _utf16_guess(raw[:SNIFF])
114
+ if guessed:
115
+ return raw.decode(guessed, errors="replace")
116
+ if _looks_binary(raw[:SNIFF]):
117
+ return None
118
+ return raw.decode("utf-8", errors="replace")
119
+
120
+
121
+ # --------------------------------------------------------------------------
122
+ # Office Open XML (xlsx, docx, pptx): stdlib only
123
+ # --------------------------------------------------------------------------
124
+
125
+ _NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
126
+
127
+
128
+ def _xlsx_text(zf: zipfile.ZipFile) -> str:
129
+ import xml.etree.ElementTree as ET
130
+
131
+ names = zf.namelist()
132
+ shared: list[str] = []
133
+ if "xl/sharedStrings.xml" in names:
134
+ root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
135
+ for si in root.iter(_NS + "si"):
136
+ shared.append("".join(t.text or "" for t in si.iter(_NS + "t")))
137
+ lines: list[str] = []
138
+ sheets = sorted(n for n in names if n.startswith("xl/worksheets/") and n.endswith(".xml"))
139
+ for name in sheets:
140
+ root = ET.fromstring(zf.read(name))
141
+ for row in root.iter(_NS + "row"):
142
+ vals = []
143
+ for c in row.iter(_NS + "c"):
144
+ kind = c.get("t")
145
+ v = c.find(_NS + "v")
146
+ index = v.text if v is not None and v.text else ""
147
+ if kind == "s" and index.isdigit() and int(index) < len(shared):
148
+ val = shared[int(index)]
149
+ elif kind == "inlineStr":
150
+ val = "".join(t.text or "" for t in c.iter(_NS + "t"))
151
+ else:
152
+ val = v.text if v is not None and v.text else ""
153
+ vals.append(val.replace(",", " ").replace("\n", " "))
154
+ lines.append(",".join(vals))
155
+ lines.append("")
156
+ return "\n".join(lines)
157
+
158
+
159
+ def _wordml_text(xml: str, para: str, cell: str | None, row: str | None) -> str:
160
+ if cell:
161
+ xml = re.sub(rf"</{para}>(?=\s*</{cell}>)", "", xml)
162
+ xml = xml.replace(f"</{cell}>", ",")
163
+ if row:
164
+ xml = xml.replace(f"</{row}>", "\n")
165
+ xml = xml.replace(f"</{para}>", "\n")
166
+ return html.unescape(re.sub(r"<[^>]+>", "", xml))
167
+
168
+
169
+ class Unreadable(Exception):
170
+ """A container phi-gate should be able to read but cannot: fail closed."""
171
+
172
+
173
+ def archive_text(path: Path, max_bytes: int) -> str:
174
+ """Text inside a zip container: Office Open XML, OpenDocument, or plain
175
+ text members of an ordinary zip archive."""
176
+ import xml.etree.ElementTree as ET
177
+
178
+ try:
179
+ with zipfile.ZipFile(path) as zf:
180
+ infos = zf.infolist()
181
+ if sum(i.file_size for i in infos) > 4 * max_bytes:
182
+ raise Unreadable(f"archive expands beyond 4 x max_bytes ({max_bytes})")
183
+ text = _zip_text(zf, infos, max_bytes)
184
+ except Unreadable:
185
+ raise
186
+ except (zipfile.BadZipFile, zipfile.LargeZipFile, ET.ParseError, KeyError, ValueError, EOFError,
187
+ RuntimeError, NotImplementedError, OSError, IndexError) as exc:
188
+ raise Unreadable(f"cannot parse archive: {type(exc).__name__}") from exc
189
+ return text
190
+
191
+
192
+ def _zip_text(zf: zipfile.ZipFile, infos: list, max_bytes: int) -> str:
193
+ names = {i.filename for i in infos}
194
+ if "xl/workbook.xml" in names:
195
+ return _xlsx_text(zf)
196
+ if "word/document.xml" in names:
197
+ return _wordml_text(zf.read("word/document.xml").decode("utf-8", "replace"), "w:p", "w:tc", "w:tr")
198
+ slides = sorted(n for n in names if n.startswith("ppt/slides/slide") and n.endswith(".xml"))
199
+ if slides:
200
+ return "\n".join(
201
+ _wordml_text(zf.read(s).decode("utf-8", "replace"), "a:p", "a:tc", "a:tr") for s in slides
202
+ )
203
+ if "content.xml" in names: # OpenDocument (ods, odt, odp)
204
+ xml = zf.read("content.xml").decode("utf-8", "replace")
205
+ xml = re.sub(r"</text:p>(?=\s*</table:table-cell>)", "", xml)
206
+ xml = xml.replace("</table:table-cell>", ",").replace("</table:table-row>", "\n")
207
+ xml = xml.replace("</text:p>", "\n")
208
+ return html.unescape(re.sub(r"<[^>]+>", "", xml))
209
+ parts = []
210
+ for info in infos[:500]:
211
+ if info.is_dir():
212
+ continue
213
+ data = zf.read(info)
214
+ if data.startswith(b"PK\x03\x04"):
215
+ parts.append(f"(nested archive {info.filename} not inspected)")
216
+ continue
217
+ decoded = decode_bytes(data[:max_bytes])
218
+ if decoded is not None:
219
+ parts.append(decoded)
220
+ return "\n".join(parts)
221
+
222
+
223
+ def sqlite_text(path: Path, rows_per_table: int = 500) -> str:
224
+ """Header plus the first rows of every table, as CSV-like text."""
225
+ try:
226
+ conn = sqlite3.connect(f"file:{path}?mode=ro&immutable=1", uri=True, timeout=1)
227
+ except sqlite3.Error as exc:
228
+ raise Unreadable(f"cannot open database: {exc}") from exc
229
+ try:
230
+ tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()][:100]
231
+ out = []
232
+ for t in tables:
233
+ cur = conn.execute(f'SELECT * FROM "{t.replace(chr(34), chr(34) * 2)}" LIMIT {int(rows_per_table)}')
234
+ out.append(",".join(d[0] for d in cur.description))
235
+ for row in cur.fetchall():
236
+ out.append(",".join("" if v is None else str(v).replace(",", " ").replace("\n", " ") for v in row))
237
+ out.append("")
238
+ return "\n".join(out)
239
+ except sqlite3.Error as exc:
240
+ raise Unreadable(f"cannot read database: {exc}") from exc
241
+ finally:
242
+ conn.close()
243
+
244
+
245
+ # --------------------------------------------------------------------------
246
+ # Files and directories
247
+ # --------------------------------------------------------------------------
248
+
249
+
250
+ def _extract(p: Path, raw: bytes, cfg: Config, oversize: bool) -> tuple[str | None, str]:
251
+ """The text a file holds, whatever it is wrapped in, and a note saying how
252
+ it was read. The text is None when phi-gate cannot read the format."""
253
+ if raw.startswith(b"PK\x03\x04") and not oversize:
254
+ return archive_text(p, cfg.max_bytes), "archive contents"
255
+ if raw.startswith(b"\x1f\x8b"):
256
+ with gzip.open(p, "rb") as gz:
257
+ decoded = decode_bytes(gz.read(cfg.max_bytes))
258
+ return decoded, "gzip contents" if decoded is not None else "gzip of binary data, not inspected"
259
+ if raw.startswith(b"SQLite format 3\x00"):
260
+ return sqlite_text(p), "sqlite tables"
261
+ if raw.startswith(OPAQUE_MAGIC):
262
+ return None, "binary format, content not inspected"
263
+ text = decode_bytes(raw)
264
+ if text is None:
265
+ return None, "binary, not inspected"
266
+ return text, f"scanned first {cfg.max_bytes} bytes only" if oversize else ""
267
+
268
+
269
+ def read_text(path: str | Path, cfg: Config) -> str:
270
+ """Decode a file the way the scanner does, for commands that must see the
271
+ same text it saw. Raises Unreadable rather than returning garbage."""
272
+ p = Path(path)
273
+ with p.open("rb") as fh:
274
+ raw = fh.read(cfg.max_bytes)
275
+ try:
276
+ text, note = _extract(p, raw, cfg, p.stat().st_size > cfg.max_bytes)
277
+ except (EOFError, gzip.BadGzipFile) as exc:
278
+ raise Unreadable(f"cannot read {p.name}: {exc}") from exc
279
+ if text is None:
280
+ raise Unreadable(f"cannot read {p.name}: {note}")
281
+ return text
282
+
283
+
284
+ def scan_file(path: str | Path, cfg: Config, base: Path | None = None) -> FileResult:
285
+ p = Path(path)
286
+ if not p.is_absolute():
287
+ p = (base or cfg.root) / p
288
+ rel = cfg.relpath(p)
289
+ if cfg.is_allowed_path(p):
290
+ return FileResult(rel, Verdict(ALLOW), "allowlisted path", skipped=True)
291
+ try:
292
+ if p.is_dir():
293
+ return FileResult(rel, Verdict(ALLOW), "directory", skipped=True)
294
+ size = p.stat().st_size
295
+ oversize = size > cfg.max_bytes
296
+ if oversize and cfg.oversize == "block":
297
+ reason = f"file is {size} bytes, over max_bytes={cfg.max_bytes}; not scanned"
298
+ return FileResult(rel, Verdict(BLOCK, [], [reason]), "oversize")
299
+ with p.open("rb") as fh:
300
+ raw = fh.read(cfg.max_bytes)
301
+ except FileNotFoundError:
302
+ return FileResult(rel, Verdict(ALLOW), "file not found", skipped=True)
303
+ except OSError as exc:
304
+ return FileResult(rel, Verdict(BLOCK, [], [f"cannot read file ({exc.strerror}); not inspected"]), "unreadable")
305
+
306
+ if raw[128:132] == b"DICM": # the patient's name and ID are in the header of every image
307
+ return FileResult(rel, Verdict(BLOCK, [], ["DICOM file; patient metadata not inspected"]), "dicom")
308
+ try:
309
+ text, note = _extract(p, raw, cfg, oversize)
310
+ except (Unreadable, OSError, EOFError, gzip.BadGzipFile) as exc:
311
+ return FileResult(rel, Verdict(BLOCK, [], [f"unreadable container ({exc}); not inspected"]), "unreadable")
312
+ if text is None:
313
+ if cfg.opaque == "block":
314
+ return FileResult(rel, Verdict(BLOCK, [], [f"{note} (opaque = block)"]), "opaque")
315
+ return FileResult(rel, Verdict(ALLOW), note, skipped=True)
316
+ return FileResult(rel, scan_text(text, cfg), note)
317
+
318
+
319
+ SKIP_ABS = {"/proc", "/sys", "/dev", "/run"}
320
+
321
+
322
+ def walk(root: Path, limit: int, keep: set[str] | frozenset[str] = frozenset()) -> list[Path] | None:
323
+ """Files under root, skipping vendored and build directories other than
324
+ those named in `keep` (`.git` is always skipped). Stops early and returns
325
+ None as soon as there are more than `limit` files."""
326
+ skip = SKIP_DIRS - (set(keep) - {".git"})
327
+ out: list[Path] = []
328
+ for dirpath, dirnames, filenames in os.walk(root):
329
+ dirnames[:] = sorted(
330
+ d for d in dirnames
331
+ if d not in skip and not d.endswith(".egg-info") and os.path.join(dirpath, d) not in SKIP_ABS
332
+ )
333
+ for name in sorted(filenames):
334
+ p = Path(dirpath) / name
335
+ if p.is_file():
336
+ out.append(p)
337
+ if len(out) > limit:
338
+ return None
339
+ return out
340
+
341
+
342
+ def iter_files(paths: list[str], cfg: Config):
343
+ for raw in paths:
344
+ p = Path(raw)
345
+ if not p.is_absolute():
346
+ p = Path.cwd() / p
347
+ if p.is_file():
348
+ yield p
349
+ elif p.is_dir():
350
+ yield from walk(p, 10**9) or []
351
+ else: # a gate must not report a clean scan of a path it never saw
352
+ raise FileNotFoundError(raw)
353
+
354
+
355
+ # --------------------------------------------------------------------------
356
+ # git
357
+ # --------------------------------------------------------------------------
358
+
359
+
360
+ def _git(args: list[str], cwd: Path) -> str:
361
+ """Run git and return its output. The bytes are decoded without newline
362
+ translation: a carriage return inside a changed line is part of that line,
363
+ and turning it into a line break would throw off the hunk line counts."""
364
+ res = subprocess.run(["git", "-c", "core.quotepath=off", *args], cwd=cwd, capture_output=True, timeout=15)
365
+ if res.returncode != 0:
366
+ raise RuntimeError(f"git {' '.join(args)} failed: {res.stderr.decode('utf-8', 'replace').strip()}")
367
+ return res.stdout.decode("utf-8", "replace")
368
+
369
+
370
+ def git_toplevel(cwd: Path) -> Path | None:
371
+ try:
372
+ return Path(_git(["rev-parse", "--show-toplevel"], cwd).strip())
373
+ except (RuntimeError, OSError, subprocess.TimeoutExpired):
374
+ return None
375
+
376
+
377
+ _HUNK = re.compile(r"^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
378
+ _ESCAPES = {"a": "\a", "b": "\b", "t": "\t", "n": "\n", "v": "\v", "f": "\f", "r": "\r", '"': '"', "\\": "\\"}
379
+
380
+
381
+ def _unquote(path: str) -> str:
382
+ """A path as git prints it: C-quoted when it holds a quote, a backslash or
383
+ a control character, with octal escapes for the bytes of other characters."""
384
+ if not (len(path) >= 2 and path[0] == '"' and path[-1] == '"'):
385
+ return path
386
+ raw = bytearray()
387
+ body, i = path[1:-1], 0
388
+ while i < len(body):
389
+ ch = body[i]
390
+ if ch == "\\" and i + 1 < len(body):
391
+ nxt = body[i + 1]
392
+ if nxt in "01234567" and re.match(r"[0-7]{3}", body[i + 1:i + 4]):
393
+ raw.append(int(body[i + 1:i + 4], 8))
394
+ i += 4
395
+ continue
396
+ raw += _ESCAPES.get(nxt, nxt).encode()
397
+ i += 2
398
+ continue
399
+ raw += ch.encode()
400
+ i += 1
401
+ return raw.decode("utf-8", "replace")
402
+
403
+
404
+ def added_lines_by_file(diff: str) -> dict[str, list[tuple[int, str]]]:
405
+ """Parse `git diff -U0` output into {path: [(new_line_no, text), ...]}.
406
+
407
+ Hunk headers say how many lines follow, and those are counted rather than
408
+ recognised by their prefix: an added line reading `++ /dev/null` appears in
409
+ the diff as `+++ /dev/null` and must not be taken for a file header."""
410
+ files: dict[str, list[tuple[int, str]]] = {}
411
+ current: str | None = None
412
+ lineno = old_left = new_left = 0
413
+ for line in diff.split("\n"):
414
+ if old_left or new_left:
415
+ if line.startswith("+"):
416
+ new_left -= 1
417
+ if current is not None:
418
+ files[current].append((lineno, line[1:]))
419
+ lineno += 1
420
+ elif line.startswith("-"):
421
+ old_left -= 1
422
+ continue
423
+ if line.startswith("+++ "):
424
+ target = _unquote(line[4:].rstrip("\t"))
425
+ current = None if target == "/dev/null" else target[2:] if target.startswith("b/") else target
426
+ if current is not None:
427
+ files.setdefault(current, [])
428
+ elif m := _HUNK.match(line):
429
+ old_left = int(m.group(1) or 1)
430
+ lineno = int(m.group(2))
431
+ new_left = int(m.group(3) or 1)
432
+ return files
433
+
434
+
435
+ # Prefixes are fixed so that diff.noprefix and diff.mnemonicPrefix cannot change
436
+ # how paths read, and renames are a deletion plus an addition, so a file moved
437
+ # out of an allowed path is checked as the new file it is.
438
+ DIFF_FLAGS = [
439
+ "--no-color", "--no-ext-diff", "--no-renames", "--src-prefix=a/", "--dst-prefix=b/", "-U0", "--diff-filter=ACM",
440
+ ]
441
+
442
+ # How much of a file is read for its first line when the whole of it is not.
443
+ HEAD_BYTES = 65536
444
+ # Total bytes read whole for one check, old and new versions together, as a
445
+ # multiple of max_bytes. Past it, a file is judged from its added lines.
446
+ READ_BUDGET_FILES = 8
447
+ # Bytes requested from git in one `cat-file --batch` call.
448
+ BATCH_BYTES = 16 * 1024 * 1024
449
+
450
+ _LFS_POINTER = re.compile(rb"\Aversion https://git-lfs\.github\.com/spec/v1\n(?:[a-z0-9.-]+ [^\n]*\n)*?oid sha256:([0-9a-f]{64})\n")
451
+
452
+
453
+ @dataclass
454
+ class _Budget:
455
+ remaining: int
456
+
457
+ def take(self, size: int) -> bool:
458
+ if size > self.remaining:
459
+ return False
460
+ self.remaining -= size
461
+ return True
462
+
463
+
464
+ @dataclass
465
+ class _Content:
466
+ """What was read of one version of a file."""
467
+
468
+ data: bytes
469
+ whole: bool
470
+
471
+
472
+ def _read_objects(top: Path, names: list[str], max_bytes: int, budget: _Budget) -> dict[str, _Content]:
473
+ """Blobs named like `:path` or `HEAD:path`, read whole when they fit
474
+ max_bytes and the budget, otherwise only their first HEAD_BYTES. A name
475
+ git cannot resolve to a blob (a deleted file, a submodule, no HEAD yet) is
476
+ left out."""
477
+ names = [n for n in names if "\n" not in n and "\r" not in n]
478
+ if not names:
479
+ return {}
480
+ checked = subprocess.run(
481
+ ["git", "cat-file", "--batch-check"], cwd=top, capture_output=True, timeout=15,
482
+ input="".join(f"{n}\n" for n in names).encode(),
483
+ )
484
+ if checked.returncode != 0:
485
+ return {}
486
+ sizes: dict[str, int] = {}
487
+ for name, line in zip(names, checked.stdout.split(b"\n"), strict=False):
488
+ fields = line.split()
489
+ if len(fields) == 3 and fields[1] == b"blob" and fields[2].isdigit():
490
+ sizes[name] = int(fields[2])
491
+ whole = [n for n in sizes if sizes[n] <= max_bytes and budget.take(sizes[n])]
492
+ out = {n: _Content(_blob_head(top, n), False) for n in sizes if n not in set(whole)}
493
+ group: list[str] = []
494
+ group_bytes = 0
495
+ for name in whole + [""]:
496
+ if name and group_bytes + sizes[name] <= BATCH_BYTES:
497
+ group.append(name)
498
+ group_bytes += sizes[name]
499
+ continue
500
+ out.update(_batch(top, group))
501
+ group, group_bytes = ([name], sizes[name]) if name else ([], 0)
502
+ return out
503
+
504
+
505
+ def _batch(top: Path, names: list[str]) -> dict[str, _Content]:
506
+ if not names:
507
+ return {}
508
+ res = subprocess.run(
509
+ ["git", "cat-file", "--batch"], cwd=top, capture_output=True, timeout=60,
510
+ input="".join(f"{n}\n" for n in names).encode(),
511
+ )
512
+ if res.returncode != 0:
513
+ return {}
514
+ out: dict[str, _Content] = {}
515
+ data, pos = res.stdout, 0
516
+ for name in names:
517
+ header_end = data.find(b"\n", pos)
518
+ if header_end < 0:
519
+ break
520
+ header = data[pos:header_end].split()
521
+ pos = header_end + 1
522
+ if len(header) != 3 or not header[2].isdigit():
523
+ continue
524
+ size = int(header[2])
525
+ out[name] = _Content(data[pos:pos + size], True)
526
+ pos += size + 1
527
+ return out
528
+
529
+
530
+ def _blob_head(top: Path, name: str) -> bytes:
531
+ """The first HEAD_BYTES of a blob, without reading the rest."""
532
+ with subprocess.Popen(["git", "cat-file", "blob", name], cwd=top, stdout=subprocess.PIPE,
533
+ stderr=subprocess.DEVNULL) as proc:
534
+ assert proc.stdout is not None
535
+ head = proc.stdout.read(HEAD_BYTES)
536
+ proc.kill()
537
+ return head
538
+
539
+
540
+ def _worktree_content(top: Path, path: str, max_bytes: int, budget: _Budget) -> _Content | None:
541
+ """A file as `git commit -a` would stage it. A symbolic link is its target
542
+ path, as git stores it, and is never followed; anything that is not a
543
+ regular file or a link (a FIFO, a device) is not read at all."""
544
+ full = top / path
545
+ try:
546
+ st = os.lstat(full)
547
+ if stat.S_ISLNK(st.st_mode):
548
+ return _Content(os.fsencode(os.readlink(full)), True)
549
+ if not stat.S_ISREG(st.st_mode):
550
+ return None
551
+ if st.st_size <= max_bytes and budget.take(st.st_size):
552
+ with full.open("rb") as fh:
553
+ data = fh.read(max_bytes + 1)
554
+ return _Content(data, len(data) <= max_bytes)
555
+ with full.open("rb") as fh:
556
+ return _Content(fh.read(HEAD_BYTES), False)
557
+ except OSError:
558
+ return None
559
+
560
+
561
+ def _text_of(content: _Content | None) -> str | None:
562
+ if content is None or not content.whole:
563
+ return None
564
+ text = decode_bytes(content.data)
565
+ return None if text is None else text.removeprefix("")
566
+
567
+
568
+ def _first_line(content: _Content | None) -> str | None:
569
+ if content is None:
570
+ return None
571
+ decoded = decode_bytes(content.data[:HEAD_BYTES])
572
+ return None if decoded is None else decoded.removeprefix("").split("\n", 1)[0].rstrip("\r")
573
+
574
+
575
+ def _introduced(new: list[Finding], old: list[Finding], new_text: str, old_text: str) -> set[int]:
576
+ """Findings the new version has that the old one did not: the ids of those
577
+ in `new` left over once each is matched to an old finding of the same
578
+ category and value. A finding can be new on a line the change never
579
+ touched: rows that a new header makes into a patient table, a resource
580
+ that new evidence makes into a FHIR Patient."""
581
+ remaining = Counter((f.category, old_text[f.start:f.end]) for f in old)
582
+ fresh = set()
583
+ for f in new:
584
+ key = (f.category, new_text[f.start:f.end])
585
+ if remaining[key]:
586
+ remaining[key] -= 1
587
+ else:
588
+ fresh.add(id(f))
589
+ return fresh
590
+
591
+
592
+ def _member_of(ids: set[int]) -> Callable[[Finding], bool]:
593
+ return lambda f: id(f) in ids
594
+
595
+
596
+ def _from_added_lines(added: list[tuple[int, str]], head: str | None, cfg: Config) -> Verdict:
597
+ """A change judged from its added lines alone, read under the file's first
598
+ line, which is where a table's header is. For a file too large to read
599
+ whole within max_bytes and the read budget, or one that is not text."""
600
+ rows = ([(1, head)] if head is not None and added[0][0] != 1 else []) + added
601
+ text = "\n".join(t for _, t in rows)
602
+ findings = _findings(text, cfg, [n for n, _ in rows])
603
+ first_added = len(rows) - len(added)
604
+ starts = _line_index(text)
605
+ added_from = starts[first_added] if first_added < len(starts) else len(text)
606
+ return evaluate(findings, cfg.record_window, changed=lambda f: f.end > added_from)
607
+
608
+
609
+ def _lfs_object(top: Path, data: bytes) -> Path | None:
610
+ m = _LFS_POINTER.match(data)
611
+ if m is None:
612
+ return None
613
+ oid = m.group(1).decode()
614
+ try:
615
+ common = Path(_git(["rev-parse", "--git-common-dir"], top).strip())
616
+ except (RuntimeError, OSError, subprocess.TimeoutExpired):
617
+ return Path("/nonexistent")
618
+ common = common if common.is_absolute() else top / common
619
+ return common / "lfs" / "objects" / oid[:2] / oid[2:4] / oid
620
+
621
+
622
+ def _scan_lfs(cfg: Config, top: Path, path: str, data: bytes) -> FileResult | None:
623
+ """A file stored with git-lfs: git holds a pointer, and the content that
624
+ leaves with a push is the object it names. That object is scanned whole."""
625
+ obj = _lfs_object(top, data)
626
+ if obj is None:
627
+ return None
628
+ rel = cfg.relpath(top / path)
629
+ if obj.is_file():
630
+ result = scan_file(obj, cfg)
631
+ result.path, result.note = rel, "git-lfs object"
632
+ return result
633
+ note = "git-lfs object not in this clone, not inspected"
634
+ if cfg.opaque == "block":
635
+ return FileResult(rel, Verdict(BLOCK, [], [f"{note} (opaque = block)"]), "opaque")
636
+ return FileResult(rel, Verdict(ALLOW), note, skipped=True)
637
+
638
+
639
+ def _changed_paths(top: Path, staged: bool) -> tuple[list[str], list[str]]:
640
+ """(text, binary) paths added, copied or modified, renames counted as new
641
+ files. Text paths include those with deletions only: removing the blank
642
+ line between a header and its rows makes a table out of both."""
643
+ args = ["diff", *(["--cached"] if staged else []), "--numstat", "-z", "--no-renames", "--diff-filter=ACM"]
644
+ text: list[str] = []
645
+ binary: list[str] = []
646
+ for record in _git(args, top).split("\0"):
647
+ fields = record.split("\t", 2)
648
+ if len(fields) == 3 and fields[2]:
649
+ (binary if fields[0] == "-" else text).append(fields[2])
650
+ return text, binary
651
+
652
+
653
+ def _scan_changes(cfg: Config, top: Path, staged: bool) -> list[FileResult]:
654
+ """Every text file the change touches, judged by the findings it
655
+ introduces: the new version is scanned whole, so an added line is read
656
+ with the header or name that gives it meaning, and compared with the old
657
+ one, so PHI the file already held is not reported again."""
658
+ text_paths, _binary = _changed_paths(top, staged)
659
+ budget = _Budget(READ_BUDGET_FILES * cfg.max_bytes)
660
+ results: list[FileResult] = []
661
+ todo = []
662
+ for path in text_paths:
663
+ if cfg.is_allowed_path(top / path):
664
+ results.append(FileResult(cfg.relpath(top / path), Verdict(ALLOW), "allowlisted path", skipped=True))
665
+ else:
666
+ todo.append(path)
667
+ if not todo:
668
+ return results
669
+ if staged:
670
+ new_side = _read_objects(top, [f":{p}" for p in todo], cfg.max_bytes, budget)
671
+ new = {p: new_side.get(f":{p}") for p in todo}
672
+ else:
673
+ new = {p: _worktree_content(top, p, cfg.max_bytes, budget) for p in todo}
674
+ base = "HEAD:" if staged else ":"
675
+ old_side = _read_objects(top, [f"{base}{p}" for p in todo], cfg.max_bytes, budget)
676
+ added = added_lines_by_file(_git(["diff", *(["--cached"] if staged else []), *DIFF_FLAGS], top))
677
+ for path in todo:
678
+ rel = cfg.relpath(top / path)
679
+ content = new.get(path)
680
+ if content is not None:
681
+ lfs = _scan_lfs(cfg, top, path, content.data)
682
+ if lfs is not None:
683
+ results.append(lfs)
684
+ continue
685
+ new_text = _text_of(content)
686
+ old_content = old_side.get(f"{base}{path}")
687
+ old_text = _text_of(old_content) if old_content is not None else ""
688
+ if new_text is not None and old_text is not None:
689
+ findings = _findings(new_text, cfg)
690
+ fresh = _introduced(findings, _findings(old_text, cfg), new_text, old_text)
691
+ verdict = evaluate(findings, cfg.record_window, changed=_member_of(fresh))
692
+ results.append(FileResult(rel, verdict))
693
+ elif added.get(path):
694
+ results.append(FileResult(rel, _from_added_lines(added[path], _first_line(content), cfg), "added lines"))
695
+ return results
696
+
697
+
698
+ def _binary_paths(top: Path, staged: bool) -> list[str]:
699
+ """Changed files git treats as binary. They have no added lines in a diff,
700
+ and that is every spreadsheet, archive, database and UTF-16 export."""
701
+ return _changed_paths(top, staged)[1]
702
+
703
+
704
+ def _scan_staged_blob(cfg: Config, top: Path, path: str) -> FileResult:
705
+ """Scan what the commit would contain, which can differ from the working tree."""
706
+ full = top / path
707
+ rel = cfg.relpath(full)
708
+ if cfg.is_allowed_path(full):
709
+ return FileResult(rel, Verdict(ALLOW), "allowlisted path", skipped=True)
710
+ size = int(_git(["cat-file", "-s", f":{path}"], top))
711
+ if size > cfg.max_bytes:
712
+ reason = f"staged file is {size} bytes, over max_bytes={cfg.max_bytes}; not scanned"
713
+ return FileResult(rel, Verdict(BLOCK, [], [reason]), "oversize")
714
+ blob = subprocess.run(["git", "cat-file", "blob", f":{path}"], cwd=top, capture_output=True, timeout=15)
715
+ if blob.returncode != 0:
716
+ return FileResult(rel, Verdict(BLOCK, [], ["cannot read staged file; not inspected"]), "unreadable")
717
+ with tempfile.TemporaryDirectory(prefix="phi-gate-") as tmp:
718
+ copy = Path(tmp) / Path(path).name
719
+ copy.write_bytes(blob.stdout)
720
+ result = scan_file(copy, cfg)
721
+ result.path = rel
722
+ return result
723
+
724
+
725
+ def _scan_staged(cfg: Config, top: Path) -> list[FileResult]:
726
+ results = _scan_changes(cfg, top, staged=True)
727
+ return results + [_scan_staged_blob(cfg, top, p) for p in _binary_paths(top, staged=True)]
728
+
729
+
730
+ def _scan_unstaged(cfg: Config, top: Path) -> list[FileResult]:
731
+ results = _scan_changes(cfg, top, staged=False)
732
+ return results + [scan_file(top / p, cfg) for p in _binary_paths(top, staged=False)]
733
+
734
+
735
+ def scan_git_diff(cfg: Config, cwd: Path, staged: bool = True) -> list[FileResult]:
736
+ top = git_toplevel(cwd)
737
+ if top is None:
738
+ raise RuntimeError("not a git repository")
739
+ return _scan_staged(cfg, top) if staged else _scan_unstaged(cfg, top)
740
+
741
+
742
+ def scan_commit(cfg: Config, cwd: Path, broad: bool) -> list[FileResult]:
743
+ """What a commit about to run could include. `broad` covers commands that
744
+ stage files first (`git add -A && git commit`, `git commit -a`): unstaged
745
+ changes and untracked files are checked too."""
746
+ top = git_toplevel(cwd)
747
+ if top is None:
748
+ return []
749
+ results = _scan_staged(cfg, top)
750
+ if broad:
751
+ results += _scan_unstaged(cfg, top)
752
+ untracked = [u for u in _git(["ls-files", "-o", "--exclude-standard", "-z"], top).split("\0") if u]
753
+ if len(untracked) > MAX_UNTRACKED:
754
+ results.append(FileResult(
755
+ "untracked files",
756
+ Verdict(BLOCK, [], [f"there are {len(untracked)} untracked files, too many to check before "
757
+ "`git add`; stage specific paths instead"]),
758
+ ))
759
+ else:
760
+ results += [scan_file(top / u, cfg) for u in untracked]
761
+ return results
762
+
763
+
764
+ def worst(results: list[FileResult]) -> str:
765
+ if any(r.verdict.decision == BLOCK for r in results):
766
+ return BLOCK
767
+ if any(r.verdict.decision == WARN for r in results):
768
+ return WARN
769
+ return ALLOW
770
+
771
+
772
+ __all__ = [
773
+ "FileResult", "Unreadable", "scan_text", "scan_file", "scan_git_diff", "scan_commit", "iter_files", "walk",
774
+ "added_lines_by_file", "worst", "decode_bytes", "archive_text", "sqlite_text", "read_text", "LOW", "MEDIUM",
775
+ "MAX_DIR_FILES",
776
+ ]