第一次看到 rea 这个词,多半会以为是个拼写错误,或者某个框架名前缀。实际它是 Rapid Environment Auditor 的缩写,是我在维护一堆历史项目时被逼出来的命令行工具。起因很直接:接手一个老仓库,文档里的启动步骤早就失效了,依赖关系像一团乱麻,端口也不知道被什么服务占用,配置项只能靠猜。rea 用一条命令把这些问题摊在面前——项目属于什么类型、依赖规模多大、当前监听哪些端口、.env 里定义了哪些键、Git 工作区干不干净——让我在五分钟内对陌生项目有一个靠谱的环境评估。这篇文章不是介绍文档,而是复盘这个工具的完整设计思路和落地过程,适合正在做内部效率工具、或者经常接手历史项目的同学参考。
1. 为什么做 rea:接手老项目就像拆盲盒
1.1 一个真实接手的翻车现场
前阵子我接手一个内部管理系统的仓库,同事休假前只留下一句话“代码能跑,环境有点乱”。我当时没有太在意,按照 README 里的说明执行npm run dev,结果提示端口冲突;换成自定义端口后,页面一直报登录接口 500,翻代码发现.env.example里的配置键和实际读取的键对不上。再往下查,这个项目依赖里居然同时存在两套 HTTP 客户端,版本还互相打架,而 requirements 和 package.json 同时出现,说明前后端可能共用同一个目录。我花了一整个下午才把环境跑通,真正改业务代码只花了两小时。
这不是个例。很多项目文档在写下的那一刻就已经过期,真正的环境状态只存在于“上一个能跑的人的脑子里”。与其反复找人问,不如做一个工具,直接向当前操作系统要答案。rea 的出发点就是不信任文档,不信任记忆,只信任实时采集到的进程、端口、文件和配置状态。
1.2 单点命令撑不起环境审计
随手能用的命令其实不少:npm list能看 Node 依赖,pip list能看 Python 包,netstat能看端口占用,docker ps能看容器,git status能看工作区。但问题在于它们是零散的,输出格式完全不一样,字段含义要靠人脑对齐。比如netstat在 Linux 和 Windows 上的参数不同,解析 IPv6 地址时还容易踩坑;npm list在依赖树很深时输出几百行,根本抓不住重点;docker ps只能告诉你容器在不在,不能告诉你这个容器对应哪个项目。
更重要的是,这些命令都只回答一个维度的问题。环境审计需要的是一个综合快照:这个目录里到底有什么类型的项目?依赖规模有多大?当前有哪些端口在监听? .env 里定义了哪些变量? Git 工作区是否干净?把这些信息拼成一张表,才能快速判断一个项目“能不能接手”、“能不能跑起来”。
1.3 rea 的定位:只读体检,不做改动
rea 从第一版开始就定了一个原则:只读,不修改。它不会帮你装依赖,不会改配置,不会 kill 进程,更不会启动服务。它只负责采集信息、汇总报告,然后把判断交给使用者。这个定位非常重要,因为“体检”和“手术”是两码事,一个只读工具可以让使用者放心地在一个陌生环境里执行,即使出了问题也不会被认为是它弄坏的。
所以 rea 的目标很明确:一条命令在几秒内输出一份环境体检报告,同时支持 JSON 输出,方便其他脚本继续处理。它不需要聪明到能修复一切,只需要快、准、不惹祸。
2. 先搭建报告的骨架:输出模型与探测器接口
2.1 提前定义审计结果结构
写第一版 rea 时,我犯过一个典型的错误:先写各种采集逻辑,然后在每个函数里直接print,最后发现输出格式千奇百怪,想加一个 JSON 导出难如登天。后来我重新设计,先定义了一个统一的数据结构,把所有探测器的结果都装进去。
from dataclasses import dataclass, field from typing import Any @dataclass class AuditItem: name: str # 探测器名称,如 ports status: str # ok / warn / error summary: str # 一句话结论 details: list[tuple[str, str]] = field(default_factory=list) data: dict[str, Any] = field(default_factory=dict) @dataclass class AuditResult: target_dir: str project_types: list[str] = field(default_factory=list) items: list[AuditItem] = field(default_factory=list) def to_dict(self): return { "target_dir": self.target_dir, "project_types": self.project_types, "items": [ { "name": item.name, "status": item.status, "summary": item.summary, "details": item.details, "data": item.data, } for item in self.items ], }这样做的意义是:所有探测器最终都填进同一个AuditResult,输出层只需要面对一个模型,可以轻松渲染成终端表格、Markdown 或者 JSON。以后再加新的探测器,只要返回AuditItem就行,不会污染公共输出。
2.2 每个探测器只回答一个问题
rea 的采集功能被拆成一个个“探测器”,每个探测器只负责一个维度。比如端口探测器只回答“当前有哪些监听端口”,环境变量探测器只回答“.env 里有什么键”。它们的接口是统一的:
from abc import ABC, abstractmethod class BaseProbe(ABC): name: str @abstractmethod def collect(self, context: AuditContext) -> AuditItem: """执行采集,返回审计项"""AuditContext里至少包含目标目录路径、检测出的项目类型等公共信息。探测器之间不互相调用,也没有共享状态。好处是调试简单:某个探测器出问题,直接跑那一个函数就行,不影响其他部分。坏处是如果多个探测器都要读取同一个大文件,会产生重复 IO,后面可以用缓存解决,但设计上仍然保持独立。
2.3 为什么选择 Python 而不是 Go 或 Rust
很多朋友会问,一个命令行工具为什么不用 Go 写,编译出来单文件多方便。我的理由很实际:rea 最核心的两个依赖是跨平台端口信息采集和终端富文本渲染,Python 生态里psutil和rich已经非常成熟,直接拿来用可以大幅缩短开发时间。Python 在本地脚本场景下的调试成本也低,改了代码立刻能跑,不需要反复交叉编译。
代价确实存在:打包成单文件后体积会到十几 MB,启动速度比 Go 二进制慢一截。但对于一个运行几秒钟的审计工具来说,这点代价完全可接受。工具的价值在于准确度和使用体验,而不是二进制体积。
3. 核心探测器是怎么写的:类型识别、依赖、端口、环境变量
3.1 项目类型识别要按权重排序
项目类型决定了后面用什么策略做依赖审计。我用一组标记文件来判断目录属于哪类项目:
MARKERS = [ ("node", ["package.json"]), ("python", ["requirements.txt", "pyproject.toml", "Pipfile", "setup.py"]), ("java", ["pom.xml", "build.gradle"]), ("go", ["go.mod"]), ("docker", ["docker-compose.yml", "Dockerfile"]), ]这里有个细节:一个目录可能同时存在package.json和docker-compose.yml,比如一个用 Docker 跑前端服务的项目。这种情况下,我会把识别结果分成“主类型”和“附属类型”:优先按package.json判定为 Node 项目,同时把 Docker 标记出来。主类型决定依赖分析策略,附属类型直接影响报告内容的展示顺序。
实现上不是找到第一个标记就停,而是把所有命中的标记都记下来,再按一个预设的优先级排序。不然的话,一个同时包含setup.py和pyproject.toml的目录,可能因为遍历顺序不同得到不同结果。
3.2 依赖规模不能只看 package.json
依赖规模是最容易误导人的指标。如果只看package.json里的dependencies和devDependencies数量,和一个真实的安装依赖数可能差得很远。因为锁文件里包含了所有间接依赖,项目实际拉下来的包往往是顶层依赖的好几倍。
rea 的做法是分层统计:先读package.json,拿到顶层依赖数量;再读package-lock.json,统计其中的resolved节点数量作为真实依赖规模。如果锁文件超过 20MB,就不再完整解析,只记录一个“lock 文件过大,跳过精确统计”的提示,避免审计工具本身卡死。Python 项目同理,requirements.txt只统计非注释、非空行,但如果存在poetry.lock或Pipfile.lock,则以锁文件为准。
这里要特别注意:不要用手写正则去数依赖名,很容易被复杂的语义绕进去,直接交给 JSON 解析或者解析器处理才是稳妥的。
3.3 端口占用:用 psutil 而不是文本解析
端口探测是 rea 里含金量最高的一块。很多脚本喜欢执行netstat -ano然后分割文本,但不同系统输出格式不一样,Windows 的 TCP 连接行和 Linux 的行字段位置不同,IPv6 地址还带着冒号,用空格切分很容易翻车。
rea 使用psutil.net_connections(),它返回结构化的连接对象,跨平台表现一致。核心逻辑如下:
import psutil from psutil import AccessDenied, NoSuchProcess def collect_listening_ports() -> AuditItem: item = AuditItem(name="ports", status="ok", summary="") listeners = [] try: conns = psutil.net_connections(kind="inet") except AccessDenied: return AuditItem( name="ports", status="warn", summary="Permission denied, port information incomplete", ) for conn in conns: if conn.status != "LISTEN": continue proc_name = "" if conn.pid: try: proc_name = psutil.Process(conn.pid).name() except (AccessDenied, NoSuchProcess): proc_name = "restricted" listeners.append({ "port": conn.laddr.port, "address": conn.laddr.ip, "pid": conn.pid, "process": proc_name, }) listeners.sort(key=lambda x: x["port"]) item.summary = f"{len(listeners)} listening port(s) found" item.details = [(f"port:{p['port']}", f"{p['process']}(pid:{p['pid']})") for p in listeners] item.data = {"ports": listeners} return item选择psutil而不是外部命令,本质上是把“解析字符串”这个易错环节交给了库,让程序直接操作对象。要注意的是在某些 Linux 环境下,获取其他用户的进程名会被内核限制,抛出AccessDenied,这时我会把进程名显示为restricted,而不是让整个工具崩溃。
3.4 环境变量必须脱敏输出
环境变量是整个审计报告里最容易泄露敏感信息的地方。很多教程会直接读取.env并打印所有值,这在本地可以,但一旦报告分享给同事或者上传到 CI 就成了安全隐患。
rea 的规则很简单:只输出键名,不输出值。对所有键做一次敏感词匹配,如果键名包含token、password、secret、key、credential等词,就显示[SET];其他键也只显示值的长度,比如[LEN:24]。这样审计对象能知道“这个变量存在”,但无法知道具体内容。
import re from dotenv import dotenv_values SENSITIVE_PATTERN = re.compile(r"(token|password|secret|key|credential|passwd)", re.I) def summarize_env_file(path): try: values = dotenv_values(path) except Exception: return [] summary = [] for key, value in values.items(): if SENSITIVE_PATTERN.search(key) or value is None: marker = "[SET]" if value is not None else "[EMPTY]" else: marker = f"[LEN:{len(value)}]" summary.append((key, marker)) return summary解析.env时千万不要自己写split("="),因为值里可能带#、引号、转义符,这会在后面的常见问题里详细展开。
3.5 Git 状态和脚本只暴露必要信息
Git 状态能让审计者快速判断这个仓库是否处于可交付状态。rea 用git branch --show-current拿当前分支,用git status --porcelain统计改动文件数。注意这里传参时统一使用列表形式,不开启shell=True,避免任何路径注入的风险。路径参数虽然是用户传入的,但固定参数列表可以降低意外执行其他命令的概率。
对于package.json里的 scripts,rea 只展示脚本的键名,不展示命令原文。原因有两个:一是命令里可能包含TOKEN=xxx这类敏感前缀,二是脚本内容往往很长,打印出来会刷屏。只展示“有哪些钩子”足以让审计者判断项目是否有启动、构建、测试脚本,具体内容要看的时候再手动打开。
4. 从零搭出 rea 的完整实操
4.1 初始化项目与 CLI 骨架
先创建一个干净的虚拟环境,然后安装依赖:
mkdir rea && cd rea python -m venv .venv source .venv/bin/activate pip install typer rich psutil python-dotenvCLI 部分选择 Typer,因为它能根据函数的参数自动生成--help,省掉大量 argparse 样板代码。最小骨架:
import typer from rich.console import Console from rich.table import Table app = typer.Typer() console = Console() @app.command() def audit(path: str = "."): """Run a full environment audit.""" result = run_audit(path) render_console(result) @app.command() def json(path: str = "."): """Output audit result as JSON.""" result = run_audit(path) print(json.dumps(result.to_dict(), indent=2, ensure_ascii=False)) if __name__ == "__main__": app()这一步不需要写任何采集逻辑,先让命令能跑,然后再把探测器一个个挂进去。我习惯先做“空壳”,因为这样能随时验证命令入口和参数传递是否正确,避免攒了大量代码后再排查问题。
4.2 编写端口探测器并接入主流程
把第三节里的端口探测器放到probes.py,然后在主函数中注册:
from probes import collect_listening_ports def run_audit(path: str) -> AuditResult: result = AuditResult(target_dir=path) result.project_types = detect_project_types(path) result.items.append(collect_listening_ports()) result.items.append(collect_env_vars(path)) result.items.append(collect_dependencies(path)) result.items.append(collect_git_state(path)) return result接入顺序会影响输出顺序。我先放端口和环境变量,因为它们最能反映“当前运行状态”;依赖和 Git 放在后面,属于项目自身的静态信息。这里不需要做并行采集,因为本地工具的目标用户是人,等待一两秒可以接受;但如果审计的目录很大,后面可以考虑用concurrent.futures并发跑探测器,只要保证每个探测器内部不共享可变状态即可。
4.3 用 Rich 渲染最终报告
Rich 的表格组件非常适合展示审计结果。每个AuditItem渲染成一张小表格,上方用rule分隔目标目录:
def render_console(result: AuditResult) -> None: console.rule(f"[bold]rea audit: {result.target_dir}") for item in result.items: table = Table(title=item.name) table.add_column("字段", no_wrap=True) table.add_column("值") if item.details: for key, value in item.details: table.add_row(key, value) else: table.add_row("summary", item.summary) console.print(table)Rich 还有个好处是自动适配终端宽度,太长的值会折行,不会把表格撑爆。如果想在 CI 里使用,可以加一个--max-width参数,强制让输出宽度固定,方便日志查看。
4.4 打包成单文件命令
开发调试时用python rea.py没问题,但真正给团队用还是需要一条全局命令。我选择用 PyInstaller 打包:
pip install pyinstaller pyinstaller -F -n rea rea.py-F表示生成单文件,-n rea指定输出名。打包完成后把dist/rea放到 PATH 目录下,或者用pipx直接安装源码包。PyInstaller 对rich和psutil的 hook 处理得比较成熟,一般不会缺动态库;如果遇到找不到包的情况,可以加--collect-all rich强制收集。
打包后的第一个命令建议跑rea audit . --help,确认入口正常。Windows 上可能需要多一步:右键以管理员身份运行命令行,因为部分端口信息需要更高权限才能拿到进程名。这个问题不影响普通使用,只是会让进程名显示成restricted。
5. 常见问题与排查实录
5.1 端口采集慢得让人想砸键盘
我在一台连接数较多的服务器上测试时,psutil.net_connections()跑一次竟然要十几秒,完全违背了“快”的初衷。原因是它会遍历所有连接,包括大量CLOSE_WAIT和TIME_WAIT状态的连接,这些对象对审计没有意义但拖慢了速度。
解决办法是只关心LISTEN状态,并且在拿到所有连接前先按状态过滤。更激进的方案是在 Linux 上改用ss -tlnp命令,它直接从内核 socket 信息中读取监听端口,速度远快于psutil。但为了保持跨平台统一,我的思路是:默认用psutil,一旦发现连接数超过阈值(比如 5000),就自动切换成ss并提示当前平台为 Linux。这个优化把端口探测时间从十几秒压到了几百毫秒。
5.2 .env 一行的坑:注释、引号与转义
手工解析.env是最容易翻车的部分。比如下面这一行:
DATABASE_URL=postgres://user:pass@localhost:5432/db # primary如果直接用split("=", 1),得到的值会变成postgres://... # primary,明显不对。更麻烦的是引号内也有#:
MESSAGE="hello # world"只有真正处理过转义和引号的解析器才能正确读取。rea 直接使用python-dotenv的dotenv_values(),它能按照 dotenv 规范处理注释、引号和转义。另外,如果.env文件不是标准的 UTF-8 编码,读取时最好指定encoding="utf-8",否则 Windows 下可能因为默认编码不同而解析出乱码。
5.3 权限不足导致进程名全变 restricted
在某 Linux 环境上运行 rea,端口表格里的进程名一列全是restricted,一开始以为代码写错了。后来发现是内核的ptrace_scope对普通用户读取其他进程信息的限制。这个问题在 CI 容器里尤其常见。
rea 的做法是不尝试去修改内核参数,也不建议直接sudo rea audit。只读工具在受限环境下能拿到端口和 pid 已经足够排查大部分问题,进程名显示为restricted本身就是一个信息——当前用户权限不够。如果需要完整信息,可以在命令行加一个--privileged提示,让使用者自己决定是否提升权限。
5.4 Windows 下中文输出的对齐问题
Rich 在小众终端上对 CJK 字符宽度计算偶尔不准,导致表格竖线错位。这个问题在 Windows 老版控制台最明显,换成 Windows Terminal 就能解决。也可以在代码入口强制指定输出编码:
import sys if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8")这段代码放在if __name__ == "__main__"的入口处,可以避免部分 Windows 环境因为控制台代码页导致的中文输出乱码。另外,如果审计对象是纯 ASCII 项目,输出通常不会出错,但项目名或路径一旦包含中文,就需要留意。
6. rea 后续还能怎么玩
6.1 与 CI 基线比对
环境审计最有价值的场景之一就是 CI 基线比对。团队可以把主分支上的rea audit --json输出保存为基线,然后在每次 PR 构建时重新生成当前 JSON,用脚本比较依赖集合、Git 状态和环境变量键名。任何意外变化都能在合入前被发现。
import json import sys base = json.load(open("baseline.json")) current = json.load(open("current.json")) base_deps = set(tuple(d) for d in base.get("dependencies", [])) current_deps = set(tuple(d) for d in current.get("dependencies", [])) diff = base_deps ^ current_deps if diff: print("dependency drift detected:") for d in sorted(diff): print(" ", d) sys.exit(1)端口信息不适合做基线比对,因为 CI 环境里端口号可能是动态分配的。更合理的做法是只比对“白名单端口是否存在”,而不是精确到数量和进程。
6.2 包装成内部 API
把 rea 的核心采集逻辑抽成一个run_audit(path)函数后,可以很自然地包装成 HTTP API。用 FastAPI 暴露一个/audit接口,接收path参数,返回 JSON 报告。但这个方向有一个必须注意的安全坑:任意路径扫描会让 API 变成攻击者的文件浏览工具。
解法是限定根目录,比如只允许tea/前缀下的相对路径,并校验解析后的绝对路径仍然在根目录内。还要加 token 鉴权,限制请求频率,避免有人拿它批量扫描服务器目录。如果团队没有明确需求,我更建议直接把 rea 放进 CI,而不是开一个常驻服务。
6.3 给个人工具链的启发
rea 虽然是个小工具,但它体现了一个原则:先定义数据模型,再写具体逻辑。我在最初版本里因为没有统一的数据结构,导致后面加新功能时不断修改输出层,浪费时间。如果你也打算做一个类似的命令行工具,建议先画一个“输出长什么样”的草图,再开始编码。
另外,只读原则帮了我大忙。因为 rea 不会改任何东西,所以在很多环境里敢直接跑,不用担心中毒。工具一旦被证明安全,别人就愿意信任它,这是很多内部工具得不到采纳的根本原因。
我在 rea 上最大的收获不是代码,而是意识到环境信息的价值。以前我拿到老项目第一反应是抱怨文档差,现在我会直接跑一遍 rea,让工具告诉我真实情况。如果你也经常在多个项目之间切换,不妨按这个思路做一个属于你的环境体检工具,哪怕只实现端口和依赖检查,也能在接手中省下大量时间。