feat: 공개 배포 번들 검사로 실제 병원명·비밀 키가 있으면 빌드를 실패시킨다
webforai.kr 빌드 결과에서 실제 병원명·도메인, Google·Supabase 키, reports/·assets/clients/ 파일을 찾으면 빌드를 멈춰 Vercel 배포가 진행되지 않게 한다. 목록은 plugins/public-bundle-denylist.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f1f8e6f6e4
commit
6b387c130c
29
plugins/public-bundle-denylist.json
Normal file
29
plugins/public-bundle-denylist.json
Normal file
@ -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/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
73
plugins/vite-plugin-public-bundle-guard.ts
Normal file
73
plugins/vite-plugin-public-bundle-guard.ts
Normal file
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig, loadEnv} from 'vite';
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
import { supabaseSync } from './plugins/vite-plugin-supabase-sync';
|
import { supabaseSync } from './plugins/vite-plugin-supabase-sync';
|
||||||
|
import { assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard';
|
||||||
|
|
||||||
export default defineConfig(({mode}) => {
|
export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
@ -88,6 +89,9 @@ export default defineConfig(({mode}) => {
|
|||||||
const src = path.resolve(out, from);
|
const src = path.resolve(out, from);
|
||||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to));
|
if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to));
|
||||||
}
|
}
|
||||||
|
// 정리가 끝난 결과물을 검사한다. 실제 병원명·비밀 키가 남아 있으면 빌드를 실패시켜 배포를 막는다.
|
||||||
|
// writeBundle 은 플러그인끼리 병렬로 돌 수 있어 별도 플러그인이 아니라 여기서 정리 뒤에 부른다.
|
||||||
|
assertPublicBundleClean(out);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user