o2o-infinith-demo/plugins/vite-plugin-public-bundle-guard.ts
Haewon Kam e20a90dc35 fix: 서버 전용 키가 브라우저 번들에 들어가지 않게 한다
- vite.config define 의 GEMINI_API_KEY 를 지운다
- API 대시보드의 import.meta.env 동적 조회를 공개 변수 목록으로 바꾼다. 동적 조회 때문에 VITE_ 변수 전체(VITE_CREATOMATE_API_KEY 포함)가 번들에 들어갔다
- 모든 배포 빌드 끝에 서버 전용 키 값이 번들에 있으면 빌드를 실패시킨다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:39:19 +09:00

106 lines
4.8 KiB
TypeScript

/**
* 공개 배포 번들 검사.
*
* 공개 배포(webforai.kr) 빌드 결과에 실제 병원명·비밀 키·실제 병원 파일이 남아 있으면 빌드를 실패시킨다.
* 빌드가 실패하면 Vercel 배포도 진행되지 않는다. 기능을 다시 켤 때(src/lib/site.ts) 실제 데이터가 딸려 나가는
* 사고를 사람이 놓쳐도 여기서 막는다. 2026-10-07 공개 전 점검에서 손으로 반복한 검사를 옮긴 것이다.
*
* 금지 목록은 plugins/public-bundle-denylist.json 에 있다.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
interface Rule {
label: string;
pattern: string;
}
interface Denylist {
clinics: Rule[];
secrets: Rule[];
paths: Rule[];
}
const TEXT_EXT = new Set(['.js', '.mjs', '.css', '.html', '.json', '.txt', '.xml', '.svg', '.map', '.webmanifest']);
function listFiles(dir: string, base = dir): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const full = path.join(dir, e.name);
return e.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
});
}
/** 위반 목록을 돌려준다. 비어 있으면 통과다. 비밀 키 값은 그대로 출력하지 않고 앞 6자만 보인다. */
export function findPublicBundleViolations(outDir: string, denylistPath?: string): string[] {
const here = path.dirname(fileURLToPath(import.meta.url));
const list: Denylist = JSON.parse(fs.readFileSync(denylistPath ?? path.join(here, 'public-bundle-denylist.json'), 'utf8'));
const files = listFiles(outDir);
const out: string[] = [];
for (const rule of list.paths) {
const re = new RegExp(rule.pattern, 'i');
const hits = files.filter((f) => re.test(f));
if (hits.length) out.push(`[파일] ${rule.label}: ${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ` 외 ${hits.length - 3}개` : ''}`);
}
const textRules = [
...list.clinics.map((r) => ({ ...r, kind: '병원명', secret: false })),
...list.secrets.map((r) => ({ ...r, kind: '비밀 키', secret: true })),
];
for (const file of files) {
if (!TEXT_EXT.has(path.extname(file).toLowerCase())) continue;
const text = fs.readFileSync(path.join(outDir, file), 'utf8');
for (const rule of textRules) {
const m = text.match(new RegExp(rule.pattern, 'i'));
if (!m) continue;
const shown = rule.secret ? `${m[0].slice(0, 6)}…` : m[0];
out.push(`[${rule.kind}] ${rule.label}: "${shown}" (${file})`);
}
}
return out;
}
/** 위반이 있으면 빌드를 멈춘다. */
export function assertPublicBundleClean(outDir: string): void {
const violations = findPublicBundleViolations(outDir);
if (violations.length) {
throw new Error(
`공개 배포 번들 검사 실패 (${violations.length}건). 실제 병원 정보나 비밀 키가 공개 번들에 들어갔습니다.\n` +
violations.map((v) => ` - ${v}`).join('\n') +
'\n 목록: plugins/public-bundle-denylist.json · 스위치: src/lib/site.ts',
);
}
}
// ─── 모든 배포 공통: 서버 전용 비밀값이 번들에 들어갔는지 ─────────────────────────────
// 이름에 KEY·SECRET·TOKEN·PASSWORD·SERVICE_ROLE 이 들어간 환경변수 값을 서버 전용으로 본다.
// 공개를 전제로 만든 값만 예외로 둔다. 값 자체를 대조하므로 키 형식이 바뀌어도 잡는다.
const SECRET_NAME = /(KEY|SECRET|TOKEN|PASSWORD|SERVICE_ROLE)/;
const PUBLIC_BY_DESIGN = new Set(['VITE_SUPABASE_ANON_KEY']);
/** 위반한 환경변수 이름 목록을 돌려준다. 값은 출력하지 않는다. */
export function findSecretValueLeaks(outDir: string, env: Record<string, string>): string[] {
const secrets = Object.entries(env).filter(
([name, value]) => SECRET_NAME.test(name) && !PUBLIC_BY_DESIGN.has(name) && typeof value === 'string' && value.length >= 16,
);
if (!secrets.length) return [];
const leaks = new Set<string>();
for (const file of listFiles(outDir)) {
if (!TEXT_EXT.has(path.extname(file).toLowerCase())) continue;
const text = fs.readFileSync(path.join(outDir, file), 'utf8');
for (const [name, value] of secrets) if (text.includes(value)) leaks.add(`${name} (${file})`);
}
return [...leaks];
}
/** 서버 전용 비밀값이 번들에 있으면 빌드를 멈춘다. 모든 배포(webforai.kr·infinith-demo)에 건다. */
export function assertNoSecretValues(outDir: string, env: Record<string, string>): void {
const leaks = findSecretValueLeaks(outDir, env);
if (leaks.length) {
throw new Error(
`번들 비밀값 검사 실패 (${leaks.length}건). 서버 전용 키가 브라우저 번들에 들어갔습니다. VITE_ 접두사나 vite.config define 을 확인하세요.\n` +
leaks.map((v) => ` - ${v}`).join('\n'),
);
}
}