From 6b387c130c2f896288d8f0dd61eb4a12265034e3 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Wed, 7 Oct 2026 14:21:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=9C=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EB=B2=88=EB=93=A4=20=EA=B2=80=EC=82=AC=EB=A1=9C=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=91=EC=9B=90=EB=AA=85=C2=B7=EB=B9=84=EB=B0=80?= =?UTF-8?q?=20=ED=82=A4=EA=B0=80=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=8B=A4=ED=8C=A8=EC=8B=9C=ED=82=A8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webforai.kr 빌드 결과에서 실제 병원명·도메인, Google·Supabase 키, reports/·assets/clients/ 파일을 찾으면 빌드를 멈춰 Vercel 배포가 진행되지 않게 한다. 목록은 plugins/public-bundle-denylist.json. Co-Authored-By: Claude Opus 5.5 --- plugins/public-bundle-denylist.json | 29 +++++++++ plugins/vite-plugin-public-bundle-guard.ts | 73 ++++++++++++++++++++++ vite.config.ts | 4 ++ 3 files changed, 106 insertions(+) create mode 100644 plugins/public-bundle-denylist.json create mode 100644 plugins/vite-plugin-public-bundle-guard.ts diff --git a/plugins/public-bundle-denylist.json b/plugins/public-bundle-denylist.json new file mode 100644 index 0000000..9105409 --- /dev/null +++ b/plugins/public-bundle-denylist.json @@ -0,0 +1,29 @@ +{ + "_comment": "공개 배포(webforai.kr) 빌드 결과에 있으면 빌드를 실패시키는 목록. plugins/vite-plugin-public-bundle-guard.ts 가 읽는다. 계약·공개 상황이 바뀌면 이 파일만 고친다. pattern 은 정규식(대소문자 무시)이다.", + "clinics": [ + { "label": "뷰성형외과", "pattern": "뷰성형외과|viewclinic|viewplastic|ViewclinicKR" }, + { "label": "원진성형외과", "pattern": "원진성형외과|k-wonjin|wonjin" }, + { "label": "바노바기", "pattern": "바노바기|banobagi" }, + { "label": "그랜드성형외과", "pattern": "그랜드성형외과|grandsurgery|grand_korea" }, + { "label": "서울이룸", "pattern": "이룸성형외과|seoulips" }, + { "label": "TS성형외과", "pattern": "TS성형외과|tsprs" }, + { "label": "태하", "pattern": "태하성형외과|taeha" }, + { "label": "JK성형외과", "pattern": "JK성형외과|jkplastic" }, + { "label": "ID병원", "pattern": "ID병원|idhospital" }, + { "label": "오라클피부과", "pattern": "오라클피부과" }, + { "label": "차앤박", "pattern": "차앤박" }, + { "label": "톡스앤필", "pattern": "톡스앤필" }, + { "label": "더스완성형외과", "pattern": "더스완|sswan" }, + { "label": "실측 고유값", "pattern": "프리저베|19,316|19,373" } + ], + "secrets": [ + { "label": "Google API 키(AIza)", "pattern": "AIza[0-9A-Za-z_\\-]{35}" }, + { "label": "Google API 키(AQ.)", "pattern": "AQ\\.[0-9A-Za-z_\\-]{20,}" }, + { "label": "Supabase 프로젝트 주소", "pattern": "[a-z0-9]{20}\\.supabase\\.co" }, + { "label": "JWT(Supabase 키 등)", "pattern": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\\.[A-Za-z0-9_\\-]{10,}" } + ], + "paths": [ + { "label": "실제 병원 리포트 파일", "pattern": "^reports/" }, + { "label": "병원 채널 스크린샷", "pattern": "^assets/clients/" } + ] +} diff --git a/plugins/vite-plugin-public-bundle-guard.ts b/plugins/vite-plugin-public-bundle-guard.ts new file mode 100644 index 0000000..02d4567 --- /dev/null +++ b/plugins/vite-plugin-public-bundle-guard.ts @@ -0,0 +1,73 @@ +/** + * 공개 배포 번들 검사. + * + * 공개 배포(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', + ); + } +} diff --git a/vite.config.ts b/vite.config.ts index a367ade..8dd9b6e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import {defineConfig, loadEnv} from 'vite'; import { supabaseSync } from './plugins/vite-plugin-supabase-sync'; +import { assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard'; export default defineConfig(({mode}) => { const env = loadEnv(mode, '.', ''); @@ -88,6 +89,9 @@ export default defineConfig(({mode}) => { const src = path.resolve(out, from); if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to)); } + // 정리가 끝난 결과물을 검사한다. 실제 병원명·비밀 키가 남아 있으면 빌드를 실패시켜 배포를 막는다. + // writeBundle 은 플러그인끼리 병렬로 돌 수 있어 별도 플러그인이 아니라 여기서 정리 뒤에 부른다. + assertPublicBundleClean(out); }, }, {