chore: add secret hygiene gate

This commit is contained in:
Your Name 2026-05-27 12:12:54 +08:00
parent 3c1fc15039
commit d9d9760405
5 changed files with 111 additions and 3 deletions

View File

@ -73,6 +73,7 @@ pnpm check:logs
pnpm check:brand
pnpm check:ci
pnpm check:env
pnpm check:secrets
pnpm check:artifacts
pnpm check:quality
pnpm clean:artifacts

View File

@ -10,6 +10,7 @@ pnpm check:logs
pnpm check:brand
pnpm check:ci
pnpm check:env
pnpm check:secrets
pnpm exec tsc --noEmit --pretty false
git diff --check
pnpm check:artifacts
@ -28,7 +29,7 @@ pnpm check:quality
## CI
`.github/workflows/lint.yml` 是基础质量门禁,运行 `pnpm check:quality`,负责 lint、日志治理、公开交付面品牌治理、CI hygiene、环境变量示例卫生、类型检查、diff hygiene 和 tracked 污染物检查。
`.github/workflows/lint.yml` 是基础质量门禁,运行 `pnpm check:quality`,负责 lint、日志治理、公开交付面品牌治理、CI hygiene、环境变量示例卫生、secret hygiene、类型检查、diff hygiene 和 tracked 污染物检查。
CI 和本地提交前检查复用同一个入口,避免规则漂移。
@ -76,6 +77,14 @@ pnpm check:env
`pnpm check:env` 会检查 `.env.example` 是否包含关键部署变量、是否存在重复 key、是否把 token/password/API key 这类字段填成疑似真实密钥。
## Secret Hygiene
```bash
pnpm check:secrets
```
`pnpm check:secrets` 会扫描 Git 已追踪文本文件,拦截真实形态的常见 API key、GitHub token、AWS key、Slack token、Authorization/Cookie 长值和私钥块。明显的 `xxxx`、placeholder、example 或 redaction 测试值会被允许,避免误伤文档示例。
## 首页性能烟测
```bash

View File

@ -13,7 +13,7 @@ SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes
预期:
- `pnpm check:quality` 通过 lint、日志脱敏门禁、公开交付面品牌治理、CI hygiene、环境变量示例卫生、类型检查、diff hygiene 和 tracked 污染物检查。
- `pnpm check:quality` 通过 lint、日志脱敏门禁、公开交付面品牌治理、CI hygiene、环境变量示例卫生、secret hygiene、类型检查、diff hygiene 和 tracked 污染物检查。
- `pnpm smoke:routes` 验证 `/` 返回 200。
- `/classic` 和 `/chat/test-id` 重定向到 `/`。
- `/settings` 未登录重定向到 `/login?callbackUrl=%2Fsettings`。

View File

@ -15,7 +15,8 @@
"check:brand": "node scripts/check-brand-hygiene.mjs",
"check:ci": "node scripts/check-ci-hygiene.mjs",
"check:env": "node scripts/check-env-example.mjs",
"check:quality": "pnpm lint && pnpm check:logs && pnpm check:brand && pnpm check:ci && pnpm check:env && pnpm exec tsc --noEmit --pretty false && git diff --check && pnpm check:artifacts",
"check:secrets": "node scripts/check-secrets.mjs",
"check:quality": "pnpm lint && pnpm check:logs && pnpm check:brand && pnpm check:ci && pnpm check:env && pnpm check:secrets && pnpm exec tsc --noEmit --pretty false && git diff --check && pnpm check:artifacts",
"clean:artifacts": "node scripts/clean-local-artifacts.mjs",
"smoke:routes": "node scripts/smoke-routes.mjs",
"format": "pnpm exec ultracite fix",

97
scripts/check-secrets.mjs Normal file
View File

@ -0,0 +1,97 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { extname } from "node:path";
const skippedExtensions = new Set([
".avif",
".bin",
".br",
".gif",
".ico",
".jpg",
".jpeg",
".lock",
".mp3",
".mp4",
".onnx",
".pdf",
".png",
".tar",
".tgz",
".webp",
".woff",
".woff2",
".zip",
]);
const skippedFiles = new Set(["pnpm-lock.yaml"]);
const secretPatterns = [
{ pattern: /sk-(?:proj-)?[A-Za-z0-9_-]{24,}/, reason: "OpenAI-style API key" },
{ pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, reason: "GitHub token" },
{ pattern: /AKIA[0-9A-Z]{16}/, reason: "AWS access key id" },
{ pattern: /xox[baprs]-[A-Za-z0-9-]{24,}/, reason: "Slack token" },
{ pattern: /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/, reason: "private key block" },
{ pattern: /\bAuthorization\s*[:=]\s*Bearer\s+[A-Za-z0-9._-]{24,}/i, reason: "Authorization bearer token" },
{ pattern: /\bCookie\s*[:=]\s*[^"'`\s]{40,}/i, reason: "Cookie header value" },
];
const allowedExampleLine = (line) => {
const normalized = line.toLowerCase();
return (
normalized.includes("xxxx") ||
normalized.includes("placeholder") ||
normalized.includes("replace-with") ||
normalized.includes("change-me") ||
normalized.includes("example") ||
normalized.includes("dummy") ||
normalized.includes("redaction") ||
normalized.includes("masked") ||
normalized.includes("begin private key-----\"") ||
normalized.includes("begin private key-----'")
);
};
const trackedFiles = execFileSync("git", ["ls-files"], { encoding: "utf8" })
.split(/\r?\n/)
.filter(Boolean)
.filter((file) => !skippedFiles.has(file))
.filter((file) => !skippedExtensions.has(extname(file).toLowerCase()));
const violations = [];
for (const file of trackedFiles) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
continue;
}
if (source.includes("\u0000")) {
continue;
}
const lines = source.split(/\r?\n/);
lines.forEach((line, index) => {
if (allowedExampleLine(line)) {
return;
}
for (const { pattern, reason } of secretPatterns) {
if (pattern.test(line)) {
violations.push(`${file}:${index + 1}: ${reason}: ${line.trim()}`);
}
}
});
}
if (violations.length > 0) {
console.error("Secret hygiene check failed. Remove real credentials or replace them with placeholders.");
console.error(violations.join("\n"));
process.exit(1);
}
console.log(`Secret hygiene check passed for ${trackedFiles.length} tracked text files.`);