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/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
|
+
]
|