- docs/DLP_ENVIRONMENT_NOTES.md: 白名单矩阵/密文样本/属性不可区分等实测结论与探索过程 - README: DLP 程序化特征与 is_encrypted/写入通道实现逻辑摘要
208 lines
10 KiB
Markdown
208 lines
10 KiB
Markdown
# dlp-io
|
||
|
||
`dlp-io` 为 Windows DLP 透明加密环境提供接近 `io.open` 的 Python 文件 API。非白名单进程负责业务逻辑;文件读取由已获 DLP 白名单授权的 Python helper 完成,再通过经过认证的 Windows Named Pipe 返回明文字节;文件写入通过通道探测保证落盘为未加密格式。另提供 `is_encrypted()`,在读取前判断文件是否处于 DLP 加密状态。
|
||
|
||
当前版本为 `dlp-io==0.2.0`,distribution 名是 `dlp-io`,import 名是 `dlp_io`。
|
||
|
||
DLP 环境的实测特征(白名单矩阵、密文样本字节对比、各写入候选通道的可用性实验)与判定/写入逻辑的完整推导记录见 [DLP_ENVIRONMENT_NOTES.md](DLP_ENVIRONMENT_NOTES.md)。
|
||
|
||
## 安装
|
||
|
||
### 方式一:Gitea PyPI registry(推荐)
|
||
|
||
registry 已开放匿名下载,一条命令即可安装;pip 会自动选择最匹配的 wheel:Windows 上 CPython 3.10–3.14 会得到对应的 pyd wheel(整个库编译为单个 `.pyd`),其余环境得到纯 Python wheel。
|
||
|
||
```powershell
|
||
py -m pip install --index-url https://gitea.docker.antior.cn/api/packages/antior/pypi/simple dlp-io==0.2.0
|
||
```
|
||
|
||
Package 页面:<https://gitea.docker.antior.cn/antior/-/packages/pypi/dlp-io>
|
||
|
||
### 方式二:Gitea Release 页面
|
||
|
||
发布物由 `.gitea/workflows/publish-dlp-io.yaml` 从 immutable tag `dlp-io-vX.Y.Z` 自动构建并上传,全部产物在 CI runner 上编译,不依赖本地环境。
|
||
|
||
Release 页面:<https://gitea.docker.antior.cn/antior/dlp-io/releases>
|
||
|
||
每个版本包含以下文件:
|
||
|
||
- `dlp_io-X.Y.Z-py3-none-any.whl`:纯 Python wheel,适用于任何 CPython >= 3.10 环境。
|
||
- `dlp_io-X.Y.Z-cp310-cp310-win_amd64.whl` 至 `cp314`:pyd wheel,Windows 专用,按解释器版本区分;整个库由 Cython 编译为单个二进制 `.pyd`,功能与纯 Python 版完全一致,可按对源码保护的要求选用。
|
||
- `dlp_io-X.Y.Z.tar.gz`:sdist 源码包。
|
||
- `dlp_io.py` 与 `dlp_io.cp310-win_amd64.pyd` 至 `cp314`:免安装单文件,与 wheel 内容一致;直接放进项目目录(或加入 `PYTHONPATH`)即可 `import dlp_io`,适合不便使用 pip 的环境。
|
||
|
||
按解释器版本选择对应资产,可直接用 pip 安装 URL。Windows PowerShell:
|
||
|
||
```powershell
|
||
# 纯 Python 版(任意平台)
|
||
py -m pip install https://gitea.docker.antior.cn/antior/dlp-io/releases/download/dlp-io-v0.1.1/dlp_io-0.1.1-py3-none-any.whl
|
||
|
||
# pyd 版(示例为 CPython 3.13,其它版本替换文件名中的 cp313)
|
||
py -3.13 -m pip install https://gitea.docker.antior.cn/antior/dlp-io/releases/download/dlp-io-v0.1.1/dlp_io-0.1.1-cp313-cp313-win_amd64.whl
|
||
```
|
||
|
||
也可以先从 Release 页面下载 wheel,再 `py -m pip install <文件路径>`。下载 Release 资产和 registry 包都不需要 token;发布只能在 CI 中通过注入的 `GITHUB_TOKEN` 完成,发布凭据不得写入命令行、URL 或 tracked 文件。
|
||
|
||
调用方需要安装 `dlp-io`。白名单 Python 无需安装该包:helper 源码由调用方以 UTF-8 通过 stdin 注入,且只依赖 Python 标准库,不会在磁盘创建临时 `.py` 文件。
|
||
|
||
运行环境必须是 Windows,并且机器上已有一个真正具备 DLP 明文读取权限的 Python。解释器查找顺序为:
|
||
|
||
1. `DLP_IO_PYTHON`
|
||
2. `DATAPACKER_DLP_PYTHON`(兼容 DataPacker)
|
||
3. `python`
|
||
4. `py -3`
|
||
|
||
生产环境优先把 `DLP_IO_PYTHON` 设置为审批过的 Python 绝对路径。
|
||
|
||
## io 风格用法
|
||
|
||
把原来的 `io.open` 或内置 `open` 改成 `dlp_io.open` 即可。纯读取走 helper;写入、追加和独占创建仍使用调用进程的原生文件 API。
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
import dlp_io
|
||
|
||
|
||
source = Path(r"D:\Protected\input.txt")
|
||
output = Path(r"D:\Output\result.txt")
|
||
|
||
with dlp_io.open(source, "r", encoding="utf-8", newline="") as reader:
|
||
text = reader.read()
|
||
|
||
with dlp_io.open(output, "w", encoding="utf-8", newline="\n") as writer:
|
||
writer.write(text.upper())
|
||
|
||
dlp_io.shutdown()
|
||
```
|
||
|
||
`dlp_io.open()` 保留 `io.open()` 的参数名和位置习惯,包括 `buffering`、`encoding`、`errors`、`newline`、`closefd` 和 `opener`。
|
||
|
||
模式路由规则:
|
||
|
||
- 路径型 `r`、`rt`、`rb`:通过 helper 读取,始终返回明文。
|
||
- 路径型 `w`、`a`、`x`:经过写入通道路由(见下文「未加密写入」),保证落盘为未加密文件。
|
||
- 整数文件描述符或自定义 `opener`:使用原生 `open`。
|
||
- 路径型 `+` 更新模式:显式抛出 `io.UnsupportedOperation`。
|
||
- 路径读取配合 `closefd=False`:与标准 API 一样抛出 `ValueError`。
|
||
- `rb` 配合 `buffering=0`:返回 raw stream;默认返回 buffered reader。
|
||
- 文本模式的默认 encoding 与当前 Python `io.open` 一致;跨机器文件请显式传 `encoding="utf-8"`。
|
||
|
||
## 加密状态判断
|
||
|
||
`is_encrypted(path)` 在读取前判断文件是否处于 DLP 加密状态,返回 `bool`:
|
||
|
||
```python
|
||
import dlp_io
|
||
|
||
if dlp_io.is_encrypted(r"D:\Protected\input.pptx"):
|
||
# 已加密:当前进程若是打包的 EXE(非白名单),必须经 helper 中转读取
|
||
with dlp_io.open(r"D:\Protected\input.pptx", "rb") as stream:
|
||
data = stream.read()
|
||
else:
|
||
# 未加密:可用任意方式直接读取
|
||
with dlp_io.open(r"D:\Protected\input.pptx", "rb") as stream:
|
||
data = stream.read()
|
||
```
|
||
|
||
判断原理:DLP 透明加密在磁盘上存密文,白名单进程读到明文、非白名单工具(certutil)读到原始盘内字节;加密文件与未加密文件的 attrib 属性、ADS、大小完全一致,只有内容可区分。`is_encrypted()` 先比较 certutil 原始哈希与当前进程视图哈希:不一致则文件已加密且当前进程是白名单;一致时存在歧义(文件未加密,或文件已加密但当前进程是非白名单 EXE),再比较 helper 视图与当前进程视图的前 64 KiB 定案。两种运行环境(Python/pyd 与打包 EXE)结果都正确。
|
||
|
||
代价:未加密文件需要一次完整 certutil 哈希、一次本地完整读取和一次 helper 前 64 KiB 读取;大文件请缓存判断结果,不要每次读取前重复调用。helper 未启动时会复用懒加载的默认 session,结束后照常 `dlp_io.shutdown()`。非 Windows 平台直接返回 `False`。
|
||
|
||
## 未加密写入
|
||
|
||
写入目标始终是「磁盘上保存未加密文件」:
|
||
|
||
- 打包为 EXE(非白名单进程):原生直写天然落盘明文,选中直写通道。
|
||
- Python 调试 / pyd(白名单进程):部分 DLP 策略会加密白名单进程的写入。首次写某个后缀时库会用临时探针文件实测直写是否落盘明文(用 certutil 原始哈希校验);若直写会被加密,自动改走 PowerShell 中转通道——字节经 stdin 管道传给非白名单的 powershell.exe 子进程写盘,不经过任何磁盘临时文件。通道按文件后缀缓存;两个通道都不可用时抛出 `DlpWriteError`,绝不静默写出密文。
|
||
|
||
可用环境变量跳过探测、强制指定通道(值为 `direct` 或 `powershell`):
|
||
|
||
```powershell
|
||
$env:DLP_IO_WRITE_CHANNEL = "powershell"
|
||
```
|
||
|
||
探测按后缀缓存,注意 DLP 策略若按目录区分,探针结果可能与目标目录不同;此时建议用环境变量显式指定通道。
|
||
|
||
## 配置和生命周期
|
||
|
||
默认 session 在第一次读取时懒启动,并被后续读取复用:
|
||
|
||
```python
|
||
import dlp_io
|
||
|
||
dlp_io.configure(
|
||
python_executable=r"C:\ApprovedPython\python.exe",
|
||
startup_timeout=30,
|
||
)
|
||
|
||
try:
|
||
with dlp_io.open(r"D:\Protected\payload.bin", "rb") as stream:
|
||
header = stream.read(64)
|
||
finally:
|
||
dlp_io.shutdown()
|
||
```
|
||
|
||
必须在默认 session 启动前调用 `configure()`。需要更换解释器时,先 `shutdown()`,再重新配置。`shutdown()` 和 session 的 `close()` 都是幂等操作。
|
||
|
||
## 显式 session
|
||
|
||
库或服务代码可以显式持有 session,避免全局状态:
|
||
|
||
```python
|
||
import dlp_io
|
||
|
||
with dlp_io.DlpSession.start(
|
||
python_cmd=[r"C:\ApprovedPython\python.exe"],
|
||
timeout=30,
|
||
) as session:
|
||
with session.open(r"D:\Protected\archive.dat", "rb") as stream:
|
||
stream.seek(-32, 2)
|
||
trailer = stream.read()
|
||
```
|
||
|
||
一个 session 同时只允许一个活动读取 stream。请关闭当前 stream 后再打开下一个;并行读取应由调用方创建彼此独立的 session。
|
||
|
||
## 兼容既有代码的全局 patch
|
||
|
||
只有当第三方代码无法逐个改为 `dlp_io.open()` 时,才临时 patch `builtins.open` 和 `io.open`:
|
||
|
||
```python
|
||
import dlp_io
|
||
|
||
dlp_io.configure(python_executable=r"C:\ApprovedPython\python.exe")
|
||
dlp_io.install_open_patch()
|
||
try:
|
||
run_legacy_application()
|
||
finally:
|
||
dlp_io.uninstall_open_patch()
|
||
dlp_io.shutdown()
|
||
```
|
||
|
||
在打包入口中,应先 import 完业务模块和本地资源,再安装 patch。patch 只路由路径型纯读模式,重复安装和卸载是幂等的。它不会拦截 `os.open`,也无法拦截 C 扩展内部绕过 Python `open` 的读取。
|
||
|
||
如果应用自己管理 session,可以把 `session.open_raw` 传给 `install_reader_patch()`。
|
||
|
||
## 错误和安全边界
|
||
|
||
主要异常均继承 `DlpIoError`:
|
||
|
||
- `DlpConfigurationError`:平台、配置或参数不成立。
|
||
- `DlpHelperStartError`:白名单 Python 启动、源码注入或握手失败。
|
||
- `DlpProtocolError`:协议版本、控制消息或响应结构无效。
|
||
- `DlpTransportError`:helper 在显式 EOF 前断开,或文件传输被截断。
|
||
- `DlpSessionBusyError`:同一个 session 已有活动 stream。
|
||
- `DlpWriteError`:没有任何写入通道能落盘未加密文件,或 PowerShell 中转写入失败。
|
||
|
||
helper 启动或读取失败时,库会显式报错并且不回退到当前 EXE 直接读取。DLP 直读可能返回合法长度的密文;静默回退会把数据损坏伪装成成功。
|
||
|
||
版本 1 会在 EOF 和 seek 重连时检测文件 size 的截断或增长,但不提供文件快照,也不能识别读取期间发生的同尺寸内容替换;调用方应避免并发修改输入文件。
|
||
|
||
每次 session 使用随机 32-byte authkey,AF_PIPE 连接执行 challenge-response 认证;协议控制消息和数据帧都有明确大小边界,零长度数据帧是唯一 EOF。
|
||
|
||
## 卸载
|
||
|
||
```powershell
|
||
py -m pip uninstall dlp-io
|
||
```
|