Replit 的 Secrets 工具把密钥加密保存,并以环境变量交给代码。容易出错的地方是:编辑器里设置的 Secrets 不会自动带到已发布的应用。 这篇做两个小工具:
settings.py在启动时一次性检查所有必需的密钥和配置,缺了就列出名字(不打印值)并退出,在已发布环境(REPLIT_DEPLOYMENT=1)下检查更严格;scan_secrets.py在公开项目前扫描代码里写死的密钥。
先看清成品
- 真实运行 4 种环境组合:开发缺密钥、开发正常、发布环境缺 DATABASE_URL 且开了 DEBUG、发布环境正常,结果都符合预期。
- 真实踩到:扫描器漏掉了
smtp_password = "...",因为正则的单词边界在下划线前不成立。已修复。- 5 项 pytest 通过。
合规提醒:用自己的 Replit 账号,不共享账号;用量和发布费用以 replit.com/pricing 为准,本文不写价格。公开项目的代码任何人都能看到,密钥只放 Secrets。收集用户数据的应用要遵守隐私法规;工作区策略禁止公开发布时,不要把代码挪到个人空间绕过(官方排错文档也这样要求)。
运行说明:我没有在 Replit 上创建项目或发布(没有产生费用),box 上也无法运行 Replit 的环境。真实运行过的是:本文的应用和检查脚本在 box 本机上的运行结果(2026-10-21,UTC+8)。Replit 端的行为全部来自官方文档。
demo_leaky/里的“密钥”都是我编的假值。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
settings.py |
启动时检查配置 |
scan_secrets.py |
扫描写死的密钥 |
.env.example |
需要哪些变量的清单 |
demo_leaky/ |
错误示范(假数据) |
test_90b.py |
5 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Replit 账号 | 在 https://replit.com 注册 |
| Python | 3.11 以上(用到 tomllib),pip install pytest |
关键事实
| 事实 | 来源 |
|---|---|
| Secrets 用于敏感信息(API Key、Token、数据库连接串),Configurations 用于非敏感设置(日志级别、功能开关),都以环境变量提供 | 官方 Secrets |
| AES-256 静态加密、TLS 传输加密;Static 部署不支持 Secrets | 同上 |
REPLIT_DEPLOYMENT:在已发布的项目中为 1,否则不设置 |
同上 |
| 编辑器里设置的 Secrets 不会自动带到已发布的应用,要在 Publishing 面板中添加 | 官方 发布排错 |
| 批量编辑(Edit as JSON / .env)会替换项目现有的全部 Secrets | 官方 Secrets |
文件结构
90-replit-B-src/
├── settings.py
├── scan_secrets.py
├── .env.example
├── demo_leaky/ # 错误示范
│ ├── mailer.py
│ └── .env
└── test_90b.py
第 1 步:变量清单
# 复制到 Replit 的 Secrets 工具里填真实值;不要把真实值写进这个文件
ADMIN_TOKEN=
SMTP_PASSWORD=
# 发布后需要(Replit 数据库会自动提供)
DATABASE_URL=
# 非敏感配置(放在 Secrets 工具的 Configurations 部分)
LOG_LEVEL=INFO
DEBUG=0
第 2 步:启动检查
"""启动时一次性读取并检查配置。依据 docs.replit.com:
- Secrets(敏感)和 Configurations(非敏感,如日志级别、功能开关)都以环境变量提供给代码;
- REPLIT_DEPLOYMENT 在已发布的项目里为 "1",否则不设置;
- 编辑器里设置的 Secrets 不会自动带到已发布的应用,要在 Publishing 里另外配置。
缺了必需的密钥就直接退出并列出名字(不打印值),而不是等到第一个用户请求时才报错。"""
import os, sys
from dataclasses import dataclass
REQUIRED_SECRETS = ("ADMIN_TOKEN", "SMTP_PASSWORD")
REQUIRED_IN_PROD = ("DATABASE_URL",)
@dataclass(frozen=True)
class Settings:
env: str
log_level: str
debug: bool
admin_token: str
smtp_password: str
database_url: str | None
def __repr__(self): # 防止有人 print(settings) 把密钥打进日志
return f"Settings(env={self.env!r}, log_level={self.log_level!r}, debug={self.debug}, secrets=***)"
def load(environ=None):
e = os.environ if environ is None else environ
prod = e.get("REPLIT_DEPLOYMENT") == "1"
missing = [k for k in REQUIRED_SECRETS + (REQUIRED_IN_PROD if prod else ()) if not e.get(k, "").strip()]
problems = []
if missing:
where = "Publishing 面板的 Secrets(编辑器里的不会自动带过去)" if prod else "Secrets 工具"
problems.append(f"缺少:{', '.join(missing)},请在{where}中添加")
debug = e.get("DEBUG", "0") in ("1", "true", "True")
if prod and debug:
problems.append("已发布的应用不应开启 DEBUG")
level = e.get("LOG_LEVEL", "INFO").upper()
if level not in ("DEBUG", "INFO", "WARNING", "ERROR"):
problems.append(f"LOG_LEVEL={level} 不合法")
if problems:
raise SystemExit("配置错误:\n - " + "\n - ".join(problems))
return Settings("production" if prod else "development", level, debug,
e["ADMIN_TOKEN"], e["SMTP_PASSWORD"], e.get("DATABASE_URL"))
if __name__ == "__main__":
s = load()
print("配置正常:", s)
第 3 步:密钥扫描
"""提交或公开项目之前,扫描代码里有没有写死的密钥。只报告文件、行号和类型,不打印密钥本身。
公开的 Replit 项目任何人都能看到代码(文档也提醒:公开项目、提交到公开仓库、直播共享屏幕都会泄露写死的密钥)。"""
import re, sys
from pathlib import Path
PATTERNS = [
("OpenAI 风格密钥", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}")),
("GitHub Token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
("AWS Access Key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("带密码的数据库 URL", re.compile(r"\b(postgres(?:ql)?|mysql)://[^:\s/]+:[^@\s]{3,}@")),
("赋值的密码/Token", re.compile(r"""(?i)\b\w*(password|passwd|secret|token|api_key)\w*\s*[:=]\s*["'][^"'\s]{8,}["']""")),
]
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".pythonlibs"}
SKIP_FILES = {".env.example"}
def scan(root="."):
hits = []
for p in Path(root).rglob("*"):
if not p.is_file() or p.name in SKIP_FILES or any(d in SKIP_DIRS for d in p.parts) or p.stat().st_size > 1_000_000:
continue
try:
lines = p.read_text(encoding="utf-8").splitlines()
except (UnicodeDecodeError, OSError):
continue
for n, line in enumerate(lines, 1):
if "scan-secrets: ignore" in line:
continue
for name, pat in PATTERNS:
if pat.search(line):
hits.append((p.relative_to(root).as_posix(), n, name)); break
return sorted(hits)
def main(argv=None):
root = (argv if argv is not None else sys.argv[1:] or ["."])[0]
hits = scan(root)
for f, n, name in hits: print(f"✗ {f}:{n} {name}")
gi = Path(root) / ".gitignore"
if (Path(root) / ".env").exists() and (not gi.exists() or ".env" not in gi.read_text().split()):
print("✗ 有 .env 文件但 .gitignore 没有忽略它"); hits.append((".env", 0, "gitignore"))
print(f"{len(hits)} 处疑似密钥" if hits else "✓ 没发现写死的密钥")
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main())
第 4 步:测试
import pytest, settings, scan_secrets
BASE = {"ADMIN_TOKEN": "t", "SMTP_PASSWORD": "p"}
def test_dev_ok():
s = settings.load({"ADMIN_TOKEN": "tok-XYZ-987", "SMTP_PASSWORD": "pw-ABC-654"})
assert s.env == "development" and "XYZ" not in repr(s) and "ABC" not in str(s)
def test_missing_lists_names_only():
with pytest.raises(SystemExit) as e:
settings.load({"ADMIN_TOKEN": "super-secret-value"})
msg = str(e.value)
assert "SMTP_PASSWORD" in msg and "super-secret-value" not in msg
def test_prod_needs_db_and_no_debug():
with pytest.raises(SystemExit) as e:
settings.load({**BASE, "REPLIT_DEPLOYMENT": "1", "DEBUG": "1"})
assert "DATABASE_URL" in str(e.value) and "DEBUG" in str(e.value)
def test_prod_ok():
assert settings.load({**BASE, "REPLIT_DEPLOYMENT": "1", "DATABASE_URL": "x"}).env == "production"
def test_scan_finds_prefixed_names():
hits = scan_secrets.scan("demo_leaky")
assert [(n, k) for _, n, k in hits] == [(3, "赋值的密码/Token"), (4, "带密码的数据库 URL"), (5, "OpenAI 风格密钥")]
第 5 步:运行
# 2026-10-21 22:09 UTC+8(env -i 清空环境后逐个设置变量)
$ python settings.py
配置错误:
- 缺少:ADMIN_TOKEN, SMTP_PASSWORD,请在Secrets 工具中添加
exit 1
$ ADMIN_TOKEN=t-123 SMTP_PASSWORD=p-456 python settings.py
配置正常: Settings(env='development', log_level='INFO', debug=False, secrets=***)
exit 0
$ REPLIT_DEPLOYMENT=1 ADMIN_TOKEN=t-123 SMTP_PASSWORD=p-456 DEBUG=1 python settings.py
配置错误:
- 缺少:DATABASE_URL,请在Publishing 面板的 Secrets(编辑器里的不会自动带过去)中添加
- 已发布的应用不应开启 DEBUG
exit 1
$ REPLIT_DEPLOYMENT=1 ADMIN_TOKEN=t-123 SMTP_PASSWORD=p-456 DATABASE_URL=postgresql://x LOG_LEVEL=info python settings.py
配置正常: Settings(env='production', log_level='INFO', debug=False, secrets=***)
exit 0
$ python scan_secrets.py demo_leaky
✗ mailer.py:3 赋值的密码/Token
✗ mailer.py:4 带密码的数据库 URL
✗ mailer.py:5 OpenAI 风格密钥
✗ 有 .env 文件但 .gitignore 没有忽略它
4 处疑似密钥
exit 1
$ python -m pytest -q
5 passed
怎么检查结果?
- 缺密钥时,报错里只有变量名,没有任何值。
print(settings)只会显示secrets=***。- 在 Replit 发布前,对照
.env.example在 Publishing 面板里逐个确认 Secrets。 - 扫描器基于正则,会有漏报和误报;它是最后一道提醒,不能代替“密钥从一开始就不写进代码”。
调试示例
扫描器漏掉了一个明显的密码。 第一次扫描 demo_leaky/mailer.py:
✗ mailer.py:4 带密码的数据库 URL
✗ mailer.py:5 OpenAI 风格密钥
✗ 有 .env 文件但 .gitignore 没有忽略它
3 处疑似密钥
第 3 行 smtp_password = "Winter2026!demo" 没被发现。规则是 \b(password|secret|token|…)\b\s*[:=]。\b 是单词边界,而下划线在正则里算“单词字符”,所以 smtp_password 里 _ 和 p 之间没有边界,整条规则匹配不上。实际代码里 smtp_password、db_password、github_token 这种带前缀的名字恰恰最常见。改成 \b\w*(password|…)\w*\s*[:=] 后 3 行都能发现,测试 test_scan_finds_prefixed_names 固定了行号。
另外,第 6 行故意写了一个看起来像密钥的示例字符串,并加了 # scan-secrets: ignore 注释,演示如何对确认无害的行消除误报。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 发布后报缺少密钥 | 只在编辑器里设置了 | 在 Publishing 面板也添加 |
| 批量编辑后密钥没了 | Edit as JSON/.env 会整体替换 | 先复制备份,确认完整再保存 |
| 日志里出现了密钥 | 打印了整个配置对象 | 重写 repr;只记录名字 |
| Static 部署读不到密钥 | Static 不支持 Secrets | 改用 Autoscale,或不在前端使用密钥 |
模板规则
- 必需配置在启动时一次检查完。
- 报错只写名字,不写值。
- 发布环境检查更严格。
- 提交前扫描一遍。
项目资源
| 用途 | 地址 |
|---|---|
| Replit 官方文档 | https://docs.replit.com |
| Replit Secrets(官方) | https://docs.replit.com/core-concepts/project-editor/app-setup/secrets |
| Replit 发布排错(官方) | https://docs.replit.com/build/troubleshooting |
| Replit 定价 | https://replit.com/pricing |
密钥问题最好在启动的第一秒暴露,而不是在第一个用户请求时。