chore: add secret hygiene gate
This commit is contained in:
parent
3c1fc15039
commit
d9d9760405
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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
97
scripts/check-secrets.mjs
Normal 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.`);
|
||||
Loading…
x
Reference in New Issue
Block a user