@zalkera/client 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/validate-storefront.mjs +671 -13
- package/llms.txt +13 -1
- package/package.json +3 -2
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
* 스토어프론트 키 리터럴(`oqsk_…`). E1·E2 가 모듈만 보고 값은 아무도 안 보던 자리다.
|
|
13
13
|
* I1 warning — `x-forwarded-for` 의 **첫 엔트리 채택**(`xff.split(",")[0]` 계열). 첫 엔트리는 방문자가
|
|
14
14
|
* 요청에 손으로 실은 값이라 IP 레이트리밋이 한 줄로 우회된다. `visitorIp()` 로 대체한다. I 절 주석 참고.
|
|
15
|
+
* I2 warning — IP 민감 호출 9종에 `clientIp` 를 **아예 안 넘긴다**(I1 의 반대편 끝). 서버가 부르면
|
|
16
|
+
* 백엔드가 보는 IP 가 그 서버라 사이트 전체가 한 IP 로 뭉친다. 영구 경고(승격 계획 없음).
|
|
17
|
+
* ⚠ I1 은 T4 에 오류로 **승격 예정**이다. I2 는 그 승격에 끌려가지 않도록 **싱크가
|
|
18
|
+
* 분리돼 있다**(`clientIpDeclarationSink` — 승격 대상 아님). 합치지 마라.
|
|
15
19
|
* W1 클라이언트 싱글턴 파일이 하나도 없음 → 서버 사이드 호출 패턴 미구현 의심.
|
|
16
20
|
* C1 ISR-우선 게이트(memo31 §0-12) — SEO 라우트 page 가 per-page SSR(동적 렌더)을 강제하면 실패.
|
|
17
21
|
* codegen 산출물이 홈·목록·상세·콘텐츠 페이지를 동적SSR 로 만들면 CI 를 red 로 만들어 미배포.
|
|
@@ -406,6 +410,20 @@ function clientIpSink() {
|
|
|
406
410
|
return warnings; // T4(자산 일소) 뒤 `servingSink()` 로 승격 — memo143 §3-물음3·미결 3.
|
|
407
411
|
}
|
|
408
412
|
|
|
413
|
+
/**
|
|
414
|
+
* **I2 전용 목적지 — 승격은 영구 금지다.** [clientIpSink] 와 지금은 같은 배열로 가지만 **함수가 다르다.**
|
|
415
|
+
*
|
|
416
|
+
* 왜 굳이 갈라 두나: I1 은 T4 뒤 `servingSink()` 로 **승격 예정**이고 그 승격은 위 함수의 한 줄을 고치는
|
|
417
|
+
* 일이다. I2 가 그 함수를 함께 쓰면, 남이 그 한 줄을 고치는 날 **I2 도 같이 관문이 된다** — I2 의 문서화된
|
|
418
|
+
* 오탐(4000자 상한·여러 줄 템플릿 리터럴)이 그날부터 거래처 빌드를 막는다. I2 헤더가 스스로 "싱크 분리가
|
|
419
|
+
* 선결"이라 적어 두고 결합한 채 배송하려던 것을 심의가 반려했다(5차 · 분리 비용은 이 세 줄이었다).
|
|
420
|
+
*
|
|
421
|
+
* **이 함수는 승격 대상이 아니다.** 여기를 `servingSink()` 로 바꾸려는 변경은 I2 의 계약을 깨는 것이다.
|
|
422
|
+
*/
|
|
423
|
+
function clientIpDeclarationSink() {
|
|
424
|
+
return warnings;
|
|
425
|
+
}
|
|
426
|
+
|
|
409
427
|
/**
|
|
410
428
|
* **서빙 산출물 계약 축(O)의 목적지 — 관문 모드에서도 경고다. 승격은 영구 금지다.**
|
|
411
429
|
*
|
|
@@ -482,6 +500,578 @@ function firstHopAdoption(text) {
|
|
|
482
500
|
return null;
|
|
483
501
|
}
|
|
484
502
|
|
|
503
|
+
/**
|
|
504
|
+
* ── I2 : 서버에서 IP 민감 호출을 부르면서 `clientIp` 를 안 넘긴다 ─────────────────────
|
|
505
|
+
*
|
|
506
|
+
* I1 이 "**틀린 값**을 넘기는 것"을 잰다면, I2 는 "**아예 안 넘기는 것**"을 잰다. 둘은 같은 축의 두 끝이다.
|
|
507
|
+
*
|
|
508
|
+
* 서버 사이드(RSC·route handler)에서 부르면 백엔드가 보는 IP 는 방문자가 아니라 **그 서버**다.
|
|
509
|
+
* 그래서 선언이 없으면 그 사이트의 방문자가 전부 한 IP 로 뭉친다. 결과는 **호출마다 다르다**:
|
|
510
|
+
*
|
|
511
|
+
* - `submitInquiry`·`submitLead` — 레이트리밋(테넌트×IP · 성공·실패 무관 **전 호출 계수** ·
|
|
512
|
+
* 문의 3건/60초 · 리드 30건/60초). 뭉치면 **남의 제출이 내 429** 가 된다.
|
|
513
|
+
* - 주문 계열 6종 — **IP 축**은 인가 실패만 계수한다(그 축이 성공을 막지는 않는다). 뭉치면 남의 오입력이
|
|
514
|
+
* 쌓인 뒤 오타 한 번에 403 대신 429 를 받고, 스캐너 탐지가 주문번호 축 하나로 줄어든다.
|
|
515
|
+
* ⚠ **"성공은 안 막힌다"가 무한정 참은 아니다** — 주문번호 축(실패 5회/600초)은 크리덴셜을 보기 전에
|
|
516
|
+
* **선차단**해서 전화번호가 맞는 게스트도 429 를 받는다. 그 축은 선언과 무관하다(게스트 한정).
|
|
517
|
+
* - `recordPostView` — 레이트리밋이 아니라 **조회 dedup**(`sha256(IP|User-Agent)` · 게시글별 ·
|
|
518
|
+
* UTC 달력 하루). ⚠ 그 UA 는 **방문자 것이 아니다** — 스토어프론트 서버가 백엔드를 부르므로 방문자
|
|
519
|
+
* 브라우저 UA 는 전달되지 않고, 클라이언트는 UA 를 안 붙이며 Node fetch 기본값은 상수 `"node"` 다(실측).
|
|
520
|
+
* 그래서 뭉치면 429 가 아니라 **게시글마다 하루 한 건**으로 집계가 접힌다(서빙 인스턴스 수만큼) —
|
|
521
|
+
* **0 이 아니라서 눈치채기 어렵다**(이 축이 조용히 죽는 이유가 그것이다).
|
|
522
|
+
*
|
|
523
|
+
* ⚠ **오늘 상용에서는 선언해도 승격되지 않는다.** 백엔드는 유효한 스토어프론트 키가 확인된 요청에서만
|
|
524
|
+
* 선언을 승격하는데(`StorefrontKeyResolver`), 상용은 memo79 컷오버 전이라 무키다 — 백엔드 자신이
|
|
525
|
+
* `DeclaredClientIp` 에 그렇게 적어 뒀다. 그래도 지금 넣어야 한다: 컷오버 날 **코드 변경 0 으로 자동
|
|
526
|
+
* 실효**하고, 소스는 그때 고칠 수 없는 자리(고객 레포)에 가 있다.
|
|
527
|
+
*
|
|
528
|
+
* **왜 경고인가 — 그리고 왜 영구히 경고인가.** 이 판정은 정규식이라 "정상인데 잡는" 형태를 다 못 막는다.
|
|
529
|
+
* 잘커라 레포의 같은 계열 검사기가 여섯 라운드에 걸쳐 그것을 증명했다(타입 전용 import · 헬퍼 경유 ·
|
|
530
|
+
* 동명 자기 함수 …). 그리고 **오탐의 대가가 비대칭이다** — 거짓 양성은 거래처 빌드를 막고(BuildGate 가
|
|
531
|
+
* CI 결론을 읽는다) 거짓 음성은 우리가 못 잡을 뿐이다. 그래서 [clientIpDeclarationSink] 를 쓰되 I1 과 달리
|
|
532
|
+
* **`servingSink` 승격 계획이 없다.** 관문이 필요하면 정규식이 아니라 타입/런타임 계약으로 세워야 한다.
|
|
533
|
+
*
|
|
534
|
+
* **대상을 좁힌다**: `@zalkera/client` 를 **런타임으로** 들여온 파일만 본다(`import type` 은 제외).
|
|
535
|
+
* 이게 없으면 거래처가 자기 데이터 계층에 `repo.getOrder(id)` 를 쓰는 것만으로 걸린다 — 우리가 남의
|
|
536
|
+
* 레포에 낼 수 있는 가장 나쁜 오탐이다.
|
|
537
|
+
*
|
|
538
|
+
* ⚠ **못 잡는 것**(문서화된 음성 한계 — 선례는 E3·I1): 클라이언트를 재수출한 자기 모듈만 import 한 파일,
|
|
539
|
+
* 동적 `import()`, **인스턴스 구조분해**(`const {getOrder} = client;` 뒤 `getOrder(…)`), 호출과 선언이
|
|
540
|
+
* 다른 파일에 있는 경우. **주석 위장은 안 통한다** — 주석도 문자열도 덮은 사본 하나만 보므로
|
|
541
|
+
* 후행 주석·인자 안 주석·JSDoc 예시 어느 쪽으로도 경고를 끌 수 없다.
|
|
542
|
+
* 그리고 파일이 `clientIp` 를 담은 **객체 변수**를 만들면 그 파일의 호출은 전부 통과한다 —
|
|
543
|
+
* `access` 를 위에서 만드는 정상 형태를 지키려는 의도적 완화다(오탐보다 미탐을 고른다).
|
|
544
|
+
* (`import {getOrder}` 같은 **네임드 임포트는 애초에 없다** — 이 패키지가 값으로 export 하는 것은
|
|
545
|
+
* `createZalkeraClient`·`visitorIp` 등이고 호출 메서드는 인스턴스에만 있다.)
|
|
546
|
+
* 잡지 못한 형상은 **검사기가 조용한 것이지 안전한 것이 아니다.**
|
|
547
|
+
*
|
|
548
|
+
* ⚠ **거짓 양성 한계**(이쪽도 적는다 — 미탐만 적으면 절반이다): `{context}` 를 그대로 위임받아 넘기는
|
|
549
|
+
* 함수, 다른 파일의 빌더가 만든 access 를 받는 함수, 클라이언트를 **IP 무관 용도로만** 쓰면서 동명
|
|
550
|
+
* 자기 메서드를 가진 파일(`getSiteConfig` 는 client 로, `getOrder` 는 자기 DB 로). 전부 **경고**이므로
|
|
551
|
+
* 빌드를 막지는 않는다. 억제 마커는 두지 않았다 — 노랑이 남는 쪽이, 끌 수 있는 검사기보다 낫다.
|
|
552
|
+
*
|
|
553
|
+
* 심의가 잰 오탐·미탐 중 **닫은 것**(형상을 남겨 둔다 — 다시 열리면 여기서 알아본다): 싱글턴 딥경로
|
|
554
|
+
* `@/lib/zalkera/server`, 구 브랜드 `@oneque/client`·`lib/oneque`, 인라인 타입 지정자 전용
|
|
555
|
+
* `import {type Order} from …`, 문자열 속 `//` 가 주석 제거에서 행을 삼켜 정상 파일을 걸던 전도,
|
|
556
|
+
* prettier 로 **접힌 여러 줄 import**(그리고 접힌 타입 주석 access)가 검사를 통째로 빠뜨리던 미탐,
|
|
557
|
+
* 문자열 속 괄호가 균형 스캔을 깨 **오탐·미탐 양방향**으로 틀리던 것, `$` 가 든 변수명 오탐,
|
|
558
|
+
* `"use client"` 파일에 브라우저에 없는 API 를 처방하던 것.
|
|
559
|
+
*
|
|
560
|
+
* ## 아직 **안 닫은** 것 — 전부 2026-08-10 실측이고, `src/validate-storefront.test.ts` 의
|
|
561
|
+
* "한계 대장" 시험이 **이 목록을 매 실행 검증한다**(닫히면 시험이 빨개져 이 문단을 고치게 한다).
|
|
562
|
+
*
|
|
563
|
+
* 13라운드 동안 이 목록이 세 번 낡았다 — 닫힌 문을 열렸다고 적고, 방향(오탐/미탐)을 재 보지 않고
|
|
564
|
+
* 단정했다. 낡은 문서가 다음 라운드의 덫이 됐으므로, 이제 **주장마다 시험이 붙는다.**
|
|
565
|
+
*
|
|
566
|
+
* **미탐(놓친다)**
|
|
567
|
+
* - `${}` 보간 안의 실호출 — 템플릿이 통째로 덮인다.
|
|
568
|
+
* - `}` 직후·`)` 선행의 진짜 정규식(`{} /re/`·`if (s) /re/`) — JSX·나눗셈과 구분 불가라 나눗셈으로
|
|
569
|
+
* 본다. 그 안에 **백틱**이 있으면 템플릿이 열려 형제 [stripLiterals] 소비처인 **X1 이 가드 없는
|
|
570
|
+
* 라우트를 통과**시킨다(보안 미탐).
|
|
571
|
+
* - JSX 파일 안의 키워드 뒤 정규식 — 낱말 분기를 끈 대가다. 그 안의 백틱·따옴표가 살아나 같은 파일의
|
|
572
|
+
* 선언·가드를 삼킬 수 있다(오탐). 15차에 "구간의 따옴표만 지우기"로 없애려다 **더 나빠져 되돌렸다**
|
|
573
|
+
* — 그러면 문자열 내용이 코드로 승격되고(실코퍼스 12파일 누출) 문자 클래스를 몰라 X1 이 눈멀었다.
|
|
574
|
+
* 그 기전은 지금 코드에 **없다**(되돌림 완료). 여기 남긴 것은 같은 처방을 다시 시도하지 말라는 기록이다.
|
|
575
|
+
|
|
576
|
+
* - 상한(`REGEX_SCAN_CAP`)을 넘는 정규식.
|
|
577
|
+
* - 들여쓴 **키워드 없는 재대입**(`⏎ ctx = {…}`)이 타입절의 다리가 될 수 있다.
|
|
578
|
+
* - 여러 줄 템플릿 리터럴에 담긴 코드 조각(한 줄짜리는 행머리 앵커가 막는다).
|
|
579
|
+
*
|
|
580
|
+
* **오탐(잘못 잡는다)**
|
|
581
|
+
* - 인자가 4000자를 넘으면 스캔 상한에서 잘려 그 뒤의 `clientIp` 를 못 본다.
|
|
582
|
+
* - `const access = {…}` 안에서 `clientIp` 까지 200자를 넘으면 waiver 를 잃는다(긴 메모 필드 등).
|
|
583
|
+
* - 타입 주석이 두 번 이상 접히면 waiver 를 잃는다(한 번 접힘까지 따라간다).
|
|
584
|
+
* - `function h() {}` 처럼 **`}` 뒤 재대입**으로 만든 carrier 를 못 본다(머리에 `}` 를 넣으면 JSX
|
|
585
|
+
* 속성값이 가짜 carrier 를 세워 진짜 누락을 침묵시킨다 — 오탐 쪽을 골랐다).
|
|
586
|
+
*
|
|
587
|
+
* 전부 **경고**라 빌드를 막지 않는다.
|
|
588
|
+
*/
|
|
589
|
+
const I2_METHODS = [
|
|
590
|
+
"getOrder",
|
|
591
|
+
"getShipment",
|
|
592
|
+
"cancelOrder",
|
|
593
|
+
"startPayment",
|
|
594
|
+
"confirmPayment",
|
|
595
|
+
"completeOrder",
|
|
596
|
+
"submitInquiry",
|
|
597
|
+
"submitLead",
|
|
598
|
+
"recordPostView",
|
|
599
|
+
];
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* 런타임 바인딩만 센다 — `import type {…} from "@zalkera/client"` 는 값을 안 들여온다.
|
|
603
|
+
*
|
|
604
|
+
* ⚠ **행머리 앵커(`^` + `/m`)가 필요하다.** 없으면 템플릿 리터럴에 담긴 코드 조각(codegen 산출물)이
|
|
605
|
+
* 걸린다. 이 파일이 이미 두 번 문서화한 함정이고 E1 도 그렇게 쓴다. 한 줄짜리 리터럴은 이걸로 막히고,
|
|
606
|
+
* 여러 줄 리터럴의 행머리는 못 막는다(문서화된 미해결 오탐).
|
|
607
|
+
*
|
|
608
|
+
* 절에 **따옴표를 금지**하는 것이 세미콜론 없는 스타일의 방어다. `(?!type\s)` 는 매치 시작점에서만
|
|
609
|
+
* 평가되므로, 절이 다음 줄까지 폭주하면 `import type …` 배제가 무력해진다 — 폭주하려면 반드시 앞
|
|
610
|
+
* import 의 모듈 문자열(따옴표)을 지나야 하므로 따옴표 금지가 그 교량을 끊는다.
|
|
611
|
+
* (개행 금지로 막던 시절엔 접힌 import 를 통째로 놓쳤다 — 아래.)
|
|
612
|
+
*/
|
|
613
|
+
/**
|
|
614
|
+
* ⚠ **import 절은 여러 줄에 걸친다.** 종전엔 `[^;\n]*` 라 개행을 못 넘었고, prettier 가 접은
|
|
615
|
+
* `import {\n zalkera,\n} from "@/lib/zalkera";` 형태에서 매치가 0 → **그 파일의 I2 검사가 통째로
|
|
616
|
+
* 스킵**됐다(심의 실측 4형상). 우리 자산은 접지 않아 영향이 없었지만 고객 BYO 레포에선 흔하다.
|
|
617
|
+
* 그래서 절에 개행을 허용하되 **따옴표·세미콜론은 금지**한다 — 절에는 원래 둘 다 안 나오고,
|
|
618
|
+
* 뒤 문장으로 폭주하려면 반드시 다음 import 의 모듈 문자열(따옴표)을 지나야 하기 때문이다.
|
|
619
|
+
*/
|
|
620
|
+
const I2_IMPORT_STATEMENT =
|
|
621
|
+
/^[ \t]*import\s+(?!type\s)([^;"']*?)from\s*["'](?:@(?:zalkera|oneque)\/client|[^"']*lib\/(?:zalkera|oneque)(?:\/[^"']*)?)["']/gm;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 이 파일이 클라이언트를 **런타임 값으로** 들여오는가.
|
|
625
|
+
*
|
|
626
|
+
* 문장형 `import type` 배제만으로는 부족하다 — `import {type Order, getX}` 처럼 **지정자마다** 붙는
|
|
627
|
+
* 인라인 형태가 있고, `import {type Order} from "@zalkera/client"` 는 값 바인딩이 하나도 없는데도
|
|
628
|
+
* 종전 정규식은 런타임 import 로 셌다(심의 실측 오탐). 중괄호 안이 **전부** `type X` 면 값이 없다.
|
|
629
|
+
* 중괄호 밖 기본/네임스페이스 import(`import z from`·`import * as z from`)는 언제나 값이다.
|
|
630
|
+
*
|
|
631
|
+
* 구 브랜드(`@oneque/client`·`lib/oneque`)와 싱글턴 딥경로(`lib/zalkera/server`)도 같이 본다 —
|
|
632
|
+
* E1·E2 는 이미 둘 다 보는데 I2 만 빠져 있었다(심의 관찰).
|
|
633
|
+
*/
|
|
634
|
+
function importsClientAtRuntime(text) {
|
|
635
|
+
I2_IMPORT_STATEMENT.lastIndex = 0;
|
|
636
|
+
for (const m of text.matchAll(I2_IMPORT_STATEMENT)) {
|
|
637
|
+
const clause = m[1];
|
|
638
|
+
const braces = clause.match(/\{([^}]*)\}/);
|
|
639
|
+
if (!braces) return true; // 기본·네임스페이스·부수효과 import — 값이다
|
|
640
|
+
const outside = clause.replace(/\{[^}]*\}/, "").replace(/[\s,]/g, "");
|
|
641
|
+
if (outside.length > 0) return true; // `import z, {type Order} from …`
|
|
642
|
+
const specifiers = braces[1].split(",").map((x) => x.trim()).filter(Boolean);
|
|
643
|
+
if (specifiers.length === 0) return true; // `import {} from …` — 부수효과
|
|
644
|
+
if (specifiers.some((x) => !/^type\s/.test(x))) return true;
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 두 번째 인자의 **타입이 다르다** — 처방을 하나로 쓰면 3종에 대해 틀린 코드를 가르치게 된다(심의 차단).
|
|
651
|
+
*
|
|
652
|
+
* `submitInquiry`·`submitLead`·`recordPostView` → `RequestContext` ⇒ `(input, {clientIp})`
|
|
653
|
+
* 주문 계열 6종 → `OrderAccess` ⇒ `(no, {phone, context: {clientIp}})`
|
|
654
|
+
*
|
|
655
|
+
* 틀린 쪽을 따르면 TS 레포는 excess-property 로 잡히지만 **JS 레포는 조용히 통과**한다 — 헤더가 안 붙는데
|
|
656
|
+
* 파일에 `clientIp` 문자열은 생겨 **이 검사기가 그다음부터 침묵**한다. 결함이 남은 채 경고만 사라진다.
|
|
657
|
+
*/
|
|
658
|
+
const I2_CONTEXT_DIRECT = new Set(["submitInquiry", "submitLead", "recordPostView"]);
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* ⚠ **계열은 둘이 아니라 셋이다.** `confirmPayment(orderNo, providerParams, access)` 만 access 가
|
|
662
|
+
* **세 번째**다(`src/client.ts` 실측). 이분법으로 쓰면 이 호출에 두 번째 인자를 가르치게 되고, 그러면
|
|
663
|
+
* access 가 `providerParams` 자리에 앉아 진짜 access 는 `undefined` 가 된다 — 헤더는 안 붙는데 인자 안에
|
|
664
|
+
* `clientIp` 토큰은 생겨 **이 검사기가 그 호출에 대해 영구히 침묵**한다(심의 차단, 같은 실패 모드 재발).
|
|
665
|
+
*/
|
|
666
|
+
const I2_ACCESS_THIRD = new Set(["confirmPayment"]);
|
|
667
|
+
|
|
668
|
+
function prescription(called) {
|
|
669
|
+
const direct = called.filter((fn) => I2_CONTEXT_DIRECT.has(fn));
|
|
670
|
+
const third = called.filter((fn) => I2_ACCESS_THIRD.has(fn));
|
|
671
|
+
const viaAccess = called.filter((fn) => !I2_CONTEXT_DIRECT.has(fn) && !I2_ACCESS_THIRD.has(fn));
|
|
672
|
+
const parts = [];
|
|
673
|
+
if (direct.length > 0) parts.push(`${direct.join("·")} 는 \`(첫 인자, {clientIp})\``);
|
|
674
|
+
if (viaAccess.length > 0) parts.push(`${viaAccess.join("·")} 는 \`(no, {phone, context: {clientIp}})\``);
|
|
675
|
+
if (third.length > 0) parts.push(`${third.join("·")} 는 **세 번째** 인자라 \`(no, params, {phone, context: {clientIp}})\``);
|
|
676
|
+
return parts.join(" · ");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 주석과 문자열의 **내용만** 공백으로 덮은 사본(길이 보존). I2 가 보는 유일한 사본이다.
|
|
681
|
+
*
|
|
682
|
+
* ## 왜 따로 만드나 — 두 형제가 각각 못 하는 것
|
|
683
|
+
*
|
|
684
|
+
* [stripComments] 는 문자열 속 `//`(프로토콜 상대 URL `"//cdn…"`)를 **주석으로 오인**해 행 끝까지
|
|
685
|
+
* 삼킨다. 다른 규칙에선 그 손상이 조용한 미탐으로 끝나지만 I2 는 **부재가 곧 판정**이라 오탐으로
|
|
686
|
+
* 뒤집힌다 — 삼켜진 줄에 `const ctx = {clientIp}` 가 있으면 정상 파일이 걸렸다(심의 실측).
|
|
687
|
+
* [stripCommentsAndStrings] 는 그걸 고치지만 `""` 로 **줄여** 오프셋이 어긋나 괄호 위치를 못 센다.
|
|
688
|
+
*
|
|
689
|
+
* 그래서 한 번의 좌→우 주사로 둘 다 덮고 길이를 보존한다. 덕분에 예전에 이 규칙이 얹었던 우회
|
|
690
|
+
* (원문 사본과 이중 대조, waiver 를 원문에서도 수집)가 **전부 필요 없어졌다** — 그 우회들이 만들던
|
|
691
|
+
* 대가, 즉 주석·JSDoc 에 적힌 `const access = {…clientIp…}` 관용구가 그 파일의 경고를 통째로 끄던
|
|
692
|
+
* 형상도 함께 사라진다. **우리 문서가 그 관용구를 예시로 싣고 있어서** 고객이 복사하면 실제로 밟는다.
|
|
693
|
+
*
|
|
694
|
+
* **정규식 리터럴도 안다.** 형제 [stripLiterals] 의 휴리스틱(직전 비공백 문자로 정규식/나눗셈을 가르고,
|
|
695
|
+
* 문자 클래스 `[...]` 안의 `/` 는 종료가 아니며, 같은 줄에서 안 닫히면 나눗셈으로 되돌린다)에서
|
|
696
|
+
* 출발했다. ⚠ **지금은 같지 않다** — 여기는 `<` 를 뺐고 `}` 뒤를 나눗셈으로 본다(둘 다 JSX 때문).
|
|
697
|
+
* "그대로 들여왔다"는 서술은 더는 참이 아니다. 처음엔 "몰라도 방향이 미탐이라 편향과 같다"고 적고 넘어갔는데 **실측은 반대였다** —
|
|
698
|
+
* `/["']/` 나 `/https:\/\//` 와 **같은 줄에 있는 carrier 선언이 덮여 정상 파일이 걸렸다**(오탐 2형상).
|
|
699
|
+
* 이 파일이 stripComments 에 대해 이미 "I2 는 부재가 곧 판정이라 오탐으로 뒤집힌다"고 논증해 둔 것과
|
|
700
|
+
* 자기모순이었다. 재 보지 않고 방향을 단정한 대가다.
|
|
701
|
+
*/
|
|
702
|
+
/**
|
|
703
|
+
* `/` 가 정규식의 시작인가 나눗셈인가 — **직전 비공백 문자**로 가른다(표준 휴리스틱, [stripLiterals] 와 동일).
|
|
704
|
+
* 아래 문자 뒤라면 값이 올 자리이므로 정규식이고, 그 밖(식별자·`)`·숫자 뒤)이면 나눗셈이다.
|
|
705
|
+
*/
|
|
706
|
+
const BEFORE_REGEX_LITERAL = new Set([
|
|
707
|
+
"(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "~", "^",
|
|
708
|
+
]);
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* `/` 가 정규식의 시작인가. `prev` 는 직전 비공백 문자, `prev2` 는 그 앞 비공백 문자다.
|
|
712
|
+
*
|
|
713
|
+
* `>` 가 특별하다: `=>` 뒤는 값이 올 자리라 정규식이지만 **JSX 닫는 태그 뒤는 텍스트**다. 두 글자를
|
|
714
|
+
* 봐야 갈린다 — 집합 원소 하나로 두었다가 `</b>/{총액}`·`<span>{n}</span>/월` 이 같은 줄을 덮어
|
|
715
|
+
* **진짜 호출 opener 를 지우는 미탐**을 맞았다(심의 실측). 통째로 빼면 `(s) => /re/.test(s)` 가 깨진다.
|
|
716
|
+
*
|
|
717
|
+
* `}` 뒤는 두 글자로도 못 가르므로 나눗셈으로 본다(스토어프론트 `.tsx` 에서 JSX 가 압도적이라는 판단).
|
|
718
|
+
*/
|
|
719
|
+
/**
|
|
720
|
+
* 정규식이 올 수 있는 **낱말** 뒤 — `return /re/`·`typeof /re/` 처럼 식별자 문자로 끝나지만 값이 올
|
|
721
|
+
* 자리다. 종전엔 직전 한 글자만 봐서 이런 자리를 전부 나눗셈으로 분류했고, 그 정규식 안에 **백틱**이
|
|
722
|
+
* 있으면 템플릿 리터럴이 열려 **파일 나머지를 삼켰다** — X1 이 가드 없는 `POST` 를 조용히 통과시켰다
|
|
723
|
+
* (심의 실측). 개행 종료는 `'`·`"` 만 가두고 백틱은 정당하게 개행을 넘으므로 못 가둔다.
|
|
724
|
+
*/
|
|
725
|
+
const BEFORE_REGEX_WORD = new Set([
|
|
726
|
+
// ⚠ **집합을 좁히는 것은 답이 아니었다.** 한때 산문 충돌(`Sign in / Sign up`·`delete / restore`)을
|
|
727
|
+
// 막으려고 `in`·`of`·`new`·`delete`·`do`·`case`·`else` 를 뺐는데, 그 대가로 `case /re/`
|
|
728
|
+
// (switch(true) 라우팅)·`else /re/` 의 X1 탐지를 잃었다. 그리고 **남긴 낱말도 산문에 나왔다**
|
|
729
|
+
// (`Free return/exchange within 14 days`) — "산문에 안 나오는 낱말만 둔다"는 서술 자체가 거짓이었다.
|
|
730
|
+
//
|
|
731
|
+
// 지금은 낱말이 아니라 **형태**로 가른다(아래 [isRegexStart]): `/` 다음이 공백·`{` 이거나 본문에
|
|
732
|
+
// 리터럴 공백이 있으면 산문이다. 그래서 뺐던 낱말을 도로 넣어 잃었던 탐지를 회수했고,
|
|
733
|
+
// 산문 6형상은 그대로 안 걸린다(심의 실측). 낱말 목록은 값-자리 판별용이지 산문 방어가 아니다.
|
|
734
|
+
"return", "throw", "typeof", "void", "await", "yield", "instanceof", "case", "else", "in", "of", "new", "delete", "do",
|
|
735
|
+
]);
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* 이 파일이 **JSX 를 담는가**. 낱말 뒤 정규식 분기를 켤지 가른다.
|
|
739
|
+
*
|
|
740
|
+
* ⚠ 확장자로 갈랐다가 틀렸다 — `.js` 가 JSX 를 담는다. Next App Router 는 `app/page.js`·`layout.js`
|
|
741
|
+
* 를 1급으로 취급하고(이 검사기 자신도 그렇게 센다), 우리 코퍼스에만 JSX 든 `.js`/`.mjs` 가 45개다.
|
|
742
|
+
* 같은 바이트를 확장자만 바꿔 재면 `.tsx`·`.jsx` 는 잡고 `.js` 만 놓쳤다(심의 실측).
|
|
743
|
+
*
|
|
744
|
+
* 그래서 **내용**으로 본다: 닫는 태그(`</Name`)나 자기닫기 태그가 있으면 JSX 다. 확장자는 보조로만
|
|
745
|
+
* 쓴다(`.tsx`·`.jsx` 는 JSX 가 없어도 그 집단으로 — 7.5%가 대가만 치르지만 판정이 흔들리지 않는다).
|
|
746
|
+
*/
|
|
747
|
+
function holdsJsx(file, source) {
|
|
748
|
+
if (/\.[jt]sx$/.test(file)) return true;
|
|
749
|
+
// ⚠ **주석·문자열을 뺀 사본**에서 본다. 원문을 보면 주석 한 줄(`// <Receipt />`)이나
|
|
750
|
+
// `/// <reference types="next" />`(모든 Next TS 프로젝트의 `next-env.d.ts`)로 평범한 `route.ts` 가
|
|
751
|
+
// JSX 집단에 들어가 낱말 뒤 정규식 판별이 꺼진다 — 실코퍼스 36건 중 14건이 그 오분류였다(심의 실측).
|
|
752
|
+
return /<\/[A-Za-z][\w.-]*\s*>|<[A-Za-z][\w.-]*(?:\s[^<>]*)?\/>/.test(stripComments(source));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function isRegexStart(prev, prev2, prevWord, beforeWord, next, jsx) {
|
|
756
|
+
if (prev === "}") return false;
|
|
757
|
+
if (prev === ">") return prev2 === "=";
|
|
758
|
+
// ⚠ `.` 뒤 낱말은 **프로퍼티**다 — `it.return / 2`·`map.delete / 2` 는 유효 JS 나눗셈인데
|
|
759
|
+
// 낱말만 보고 정규식으로 오판해 같은 줄 carrier 를 삼켰다(심의 실측 신규 오탐).
|
|
760
|
+
// ⚠ 낱말 분기는 **JSX 가 없는 파일에서만** 켠다. 산문(`Sign in / Sign up`)은 `.tsx` 에 살고,
|
|
761
|
+
// 키워드 뒤 정규식(`return /a`b/` — X1 이 지키는 라우트 핸들러)은 `.ts` 에 산다. 두 집단이
|
|
762
|
+
// 파일 확장자로 갈리므로 그렇게 가른다.
|
|
763
|
+
//
|
|
764
|
+
// 종전엔 한 줄 안에서 산문인지 추정하려 했다 — "본문에 공백이 있으면"(→ 공백 든 진짜 정규식을
|
|
765
|
+
// 오탐) → "닫는 슬래시 앞이 `<` 이면"(→ 산문 줄에 `24/7`·`<br/>`·href 가 섞이면 미탐, `<` 로
|
|
766
|
+
// 끝나는 진짜 정규식은 오탐). **한 글자 휴리스틱을 두 번 얹어 두 번 다 틀렸다.** 세 번째를
|
|
767
|
+
// 얹는 대신 집단을 갈랐다.
|
|
768
|
+
//
|
|
769
|
+
// ⚠ 한때 "낱말 뒤 `/` 다음이 공백·`{` 면 정규식 아님" 가드를 함께 뒀는데, 집단을 가른 뒤로는
|
|
770
|
+
// **어떤 픽스처도 그 절을 격리하지 못했다**(변이 생존 = 죽은 코드). 겹치는 방어를 남기는 것이
|
|
771
|
+
// 이 축의 반복 실패 원인이었으므로 지웠다 — 방어는 하나면 족하고, 그 하나는 시험으로 잠긴다.
|
|
772
|
+
// 그리고 **본문에 공백이 있으면 산문**으로 본다(`body` 는 같은 줄의 다음 `/` 까지). 남긴 낱말도
|
|
773
|
+
// 산문에 나온다 — `Free return/exchange within 14 days …</p>` 에서 닫는 태그가 두 번째 `/` 를
|
|
774
|
+
// 공급해 그 사이 진짜 호출이 지워졌다(심의 실측 · "산문에 안 나오는 낱말만 둔다"는 거짓이었다).
|
|
775
|
+
// 진짜 정규식은 리터럴 공백 대신 `\s` 를 쓴다.
|
|
776
|
+
if (/[\w$]/.test(prev)) {
|
|
777
|
+
if (jsx) return false;
|
|
778
|
+
return beforeWord !== "." && BEFORE_REGEX_WORD.has(prevWord);
|
|
779
|
+
}
|
|
780
|
+
return prev === "" || BEFORE_REGEX_LITERAL.has(prev);
|
|
781
|
+
}
|
|
782
|
+
// ⚠ `<` 는 **뺐다**(이식 원본에는 있다). 유효 JS 에서 `<` 뒤에 정규식이 오는 코드는 사실상 없는데,
|
|
783
|
+
// JSX 닫는 태그 `</td>` 의 `/` 를 정규식 시작으로 읽어 같은 줄 뒤쪽을 덮는다(심의 실측 3형상).
|
|
784
|
+
// `>` 는 남긴다 — `(s) => /["']/.test(s)` 는 흔한 형태다.
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* 정규식 종료 탐색의 상한. 없으면 실패하는 스캔이 반복될 때 파일 크기의 **제곱**으로 커진다.
|
|
788
|
+
*
|
|
789
|
+
* 512 인 이유: 정규식 리터럴은 짧다(세 레포 실측 최장 150자). 4000 으로 뒀더니 208KB 병리 입력이
|
|
790
|
+
* 3.7초였다 — 유계이긴 해도 게이트에서 물릴 값이 아니다.
|
|
791
|
+
*
|
|
792
|
+
* ⚠ 넘는 리터럴은 나눗셈으로 분류된다. 종전 이 자리에 "그 결과는 안 덮는다(=미탐 편향과 같은 방향)"
|
|
793
|
+
* 라고 적었는데 **거짓이다** — 안 덮는 순간 그 리터럴 안의 `'`·`` ` ``·`/*` 가 살아나 **상태 전이**를
|
|
794
|
+
* 일으켜 훨씬 넓게 덮는다(심의 실측: 600자+따옴표 하나로 정상 라우트에 거짓 경고). 방향은 오탐이다.
|
|
795
|
+
* 상한 자체는 옳지만 안전 논증이 틀렸다 — 이 축에서 방향을 재 보지 않고 단정한 것이 세 번째다.
|
|
796
|
+
*/
|
|
797
|
+
const REGEX_SCAN_CAP = 512;
|
|
798
|
+
|
|
799
|
+
function maskCode(text, jsx = false) {
|
|
800
|
+
const out = text.split("");
|
|
801
|
+
// 주석만 덮고 **문자열은 남긴** 사본. import 지정자를 뽑는 데 쓴다 — 덮인 사본에서 뽑으면
|
|
802
|
+
// `from "@/lib/zalkera"` 가 `from " "` 이 돼 모듈 판정이 조용히 끊긴다(형제 주석의 함정 1).
|
|
803
|
+
const withStrings = text.split("");
|
|
804
|
+
let quote = null;
|
|
805
|
+
let comment = null;
|
|
806
|
+
let prev = "";
|
|
807
|
+
let prev2 = "";
|
|
808
|
+
let word = "";
|
|
809
|
+
let prevWord = "";
|
|
810
|
+
let beforeWord = "";
|
|
811
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
812
|
+
const ch = text[i];
|
|
813
|
+
if (comment) {
|
|
814
|
+
if (comment === "//") {
|
|
815
|
+
if (ch === "\n") comment = null;
|
|
816
|
+
else {
|
|
817
|
+
out[i] = " ";
|
|
818
|
+
withStrings[i] = " ";
|
|
819
|
+
}
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
// ⚠ 블록 주석 종료는 **두 글자를 함께** 덮고 `i` 도 건너뛴다. 종전엔 닫는 `/` 를
|
|
823
|
+
// `withStrings` 에서 안 덮어 그 줄의 행머리 앵커가 깨졌고(같은 줄 import 를 못 봐 **그 파일의
|
|
824
|
+
// I2 가 통째로 스킵**됐다), `i` 를 안 건너뛰어 `/* a *//* b */` 처럼 붙은 주석에서 닫는 `/` 가
|
|
825
|
+
// 다음 `/` 와 짝지어 **유령 행 주석**을 열었다(그 줄 실코드가 사라졌다). 둘 다 미탐이다.
|
|
826
|
+
if (ch === "*" && text[i + 1] === "/") {
|
|
827
|
+
out[i] = " ";
|
|
828
|
+
out[i + 1] = " ";
|
|
829
|
+
withStrings[i] = " ";
|
|
830
|
+
withStrings[i + 1] = " ";
|
|
831
|
+
comment = null;
|
|
832
|
+
i += 1;
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (ch !== "\n") {
|
|
836
|
+
out[i] = " ";
|
|
837
|
+
withStrings[i] = " ";
|
|
838
|
+
}
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (quote) {
|
|
842
|
+
if (ch === "\\") {
|
|
843
|
+
out[i] = " ";
|
|
844
|
+
if (i + 1 < text.length && text[i + 1] !== "\n") out[i + 1] = " ";
|
|
845
|
+
i += 1;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (ch === quote) quote = null;
|
|
849
|
+
else if (ch === "\n" && quote !== "`") quote = null;
|
|
850
|
+
else if (ch !== "\n") out[i] = " ";
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (ch === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
|
|
854
|
+
comment = "/" + text[i + 1];
|
|
855
|
+
out[i] = " ";
|
|
856
|
+
out[i + 1] = " ";
|
|
857
|
+
withStrings[i] = " ";
|
|
858
|
+
withStrings[i + 1] = " ";
|
|
859
|
+
i += 1;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
863
|
+
quote = ch;
|
|
864
|
+
prev = ch;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
// 정규식 리터럴 — 안의 따옴표·`//` 가 상태를 오염시키지 않게 **내용만** 덮는다. 줄바꿈을 못
|
|
868
|
+
// 넘으므로 같은 줄에서만 닫는 `/` 를 찾고, 못 찾으면 나눗셈이었다고 보고 되돌린다(폭주 한 줄 제한).
|
|
869
|
+
// ⚠ 본문 첫 글자로 **정규식이 시작할 수 없는 것**을 배제한다. 없으면 JSX 의 `{a}/{b}` 에서
|
|
870
|
+
// `}` 뒤 `/` 를 정규식으로 오인해 같은 줄의 `</div>` 까지 덮고, 그 사이의 **진짜 호출 opener 가
|
|
871
|
+
// 사라져 미탐**이 된다(심의 실측 — 5차가 만든 회귀). `{y}/{m}/{d}`·`{used}/{total}` 은
|
|
872
|
+
// 스토어프론트 page.tsx 의 평범한 형태이고, 그 파일이 바로 주문 호출을 하는 파일이다.
|
|
873
|
+
// 대가: `/>/` 같은 리터럴은 나눗셈으로 분류된다(드물다).
|
|
874
|
+
// ⚠ **`}` 뒤 슬래시는 나눗셈으로 본다.** 그 자리는 JSX 와 정규식을 구분할 수 없는데, 이 레포가
|
|
875
|
+
// 다루는 것은 스토어프론트 `.tsx` 라 JSX 쪽이 압도적이다. 처음엔 "다음 글자가 `{` 나 공백일 때만"
|
|
876
|
+
// 배제했는데, `{price}/월`·`{n}/mo`·`{used}/개` 처럼 **텍스트가 이어지는 형태**가 그대로 뚫려
|
|
877
|
+
// 같은 줄의 `</div>` 까지 덮고 그 사이의 **진짜 호출 opener 를 지웠다**(심의 실측 4형상·미탐).
|
|
878
|
+
// 두 칸만 막고 열린 칸을 안 잰 것이다.
|
|
879
|
+
//
|
|
880
|
+
// 대가: `{} /re/` 처럼 `}` 직후의 진짜 정규식은 안 덮인다. 그 안에 따옴표가 있으면 상태가
|
|
881
|
+
// 흔들려 오탐이 될 수 있다(아래 한계). JSX 미탐보다 이쪽이 드물다는 판단이다.
|
|
882
|
+
if (ch === "/" && isRegexStart(prev, prev2, word || prevWord, beforeWord, text[i + 1], jsx)) {
|
|
883
|
+
let j = i + 1;
|
|
884
|
+
let inClass = false;
|
|
885
|
+
// ⚠ **상한을 건다.** 닫는 `/` 를 못 찾는 스캔은 줄 끝까지 가는데, 그런 후보가 한 줄에 많으면
|
|
886
|
+
// 비용이 파일 크기의 **제곱**이 된다 — 실측 후보 4000개(52KB 한 줄) 593ms, 배증마다 4배.
|
|
887
|
+
// 이 검사기는 **고객 업로드 zip 과 서빙 빌드 게이트**에서 돈다. I2 는 경고층이지만 행중단은
|
|
888
|
+
// 오류층 규칙까지 전부 세운다(심의 차단).
|
|
889
|
+
for (; j < text.length && j - i < REGEX_SCAN_CAP && text[j] !== "\n"; j += 1) {
|
|
890
|
+
// ⚠ 이스케이프가 **개행을 건너뛰지 못하게** 한다. 건너뛰면 "폭주는 한 줄"이라는 이
|
|
891
|
+
// 스캔의 유일한 안전장치가 깨져 다음 줄의 선언까지 덮는다.
|
|
892
|
+
if (text[j] === "\\") {
|
|
893
|
+
if (text[j + 1] === "\n") break;
|
|
894
|
+
j += 1;
|
|
895
|
+
} else if (text[j] === "[") inClass = true;
|
|
896
|
+
else if (text[j] === "]") inClass = false;
|
|
897
|
+
else if (text[j] === "/" && !inClass) break;
|
|
898
|
+
}
|
|
899
|
+
if (j < text.length && text[j] === "/") {
|
|
900
|
+
for (let k = i + 1; k < j; k += 1) {
|
|
901
|
+
if (text[k] !== "\n") {
|
|
902
|
+
out[k] = " ";
|
|
903
|
+
withStrings[k] = " ";
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
i = j;
|
|
907
|
+
prev = "/";
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (/[\w$]/.test(ch)) {
|
|
912
|
+
if (!word) beforeWord = prev;
|
|
913
|
+
word += ch;
|
|
914
|
+
} else if (word) {
|
|
915
|
+
prevWord = word;
|
|
916
|
+
word = "";
|
|
917
|
+
} else if (!/\s/.test(ch)) {
|
|
918
|
+
beforeWord = ch;
|
|
919
|
+
}
|
|
920
|
+
if (!/\s/.test(ch)) {
|
|
921
|
+
prev2 = prev;
|
|
922
|
+
prev = ch;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return {masked: out.join(""), withStrings: withStrings.join("")};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* 호출 이름부터 균형 잡힌 닫는 괄호까지 — 그 호출이 **자기 인자**로 무엇을 받는지 본다.
|
|
930
|
+
*
|
|
931
|
+
* ⚠ **괄호는 문자열 밖에서만 센다.** 종전엔 문자열이 살아 있는 사본에서 깊이를 세어 양방향으로 깨졌다
|
|
932
|
+
* (심의 실측): `submitInquiry({subject: "1) 견적 문의"}, {clientIp: ip})` 같은 **정상 코드가 오탐**이 됐고,
|
|
933
|
+
* `getOrder("(", {phone: p})` 는 스캔이 진짜 닫는 괄호를 지나쳐 **진짜 누락이 침묵**했다. 한국어 payload 에
|
|
934
|
+
* `1)`·`:)` 는 흔하다. "오탐보다 미탐" 편향은 이 결함을 정당화하지 못한다 — 둘 다 잃기 때문이다.
|
|
935
|
+
*
|
|
936
|
+
* 잘라 낸 조각도 **덮인 사본**에서 준다. 그래야 `{msg: "clientIp 를 넘기세요"}` 같은 문자열이 선언으로
|
|
937
|
+
* 오인돼 경고를 끄지 못한다.
|
|
938
|
+
*/
|
|
939
|
+
function callArguments(source, code, fn) {
|
|
940
|
+
const found = [];
|
|
941
|
+
const opener = new RegExp(`(?:\\.|\\?\\.)\\s*${fn}\\s*(?:\\?\\.)?\\s*\\(|\\[\\s*["'\`]${fn}["'\`]\\s*\\]\\s*\\(`, "g");
|
|
942
|
+
// ⚠ 호출 **위치**는 원본에서 찾는다 — 브래킷 접근(`zalkera["getOrder"](…)`)은 이름 자체가 문자열
|
|
943
|
+
// 안이라 덮은 사본에서는 사라진다. 깊이만 덮은 사본에서 세면 된다(길이가 같아 오프셋이 맞는다).
|
|
944
|
+
for (const m of source.matchAll(opener)) {
|
|
945
|
+
let i = m.index + m[0].length - 1;
|
|
946
|
+
// ⚠ **덮인 자리의 opener 는 호출이 아니다.** 위치를 원본에서 찾는 대가로 주석·문자열 안의
|
|
947
|
+
// `zalkera.getOrder(` 표기까지 걸린다 — 실제로 주석 처리한 호출·JSDoc 예시·TODO 한 줄이
|
|
948
|
+
// **부르지도 않은 메서드의 경고를 만들어 냈다**(심의 실측 1건). 진짜 호출의 여는 괄호는 언제나
|
|
949
|
+
// 실코드이므로, 덮여 있으면 그 자리는 표기지 호출이 아니다.
|
|
950
|
+
if (code[i] !== "(") continue;
|
|
951
|
+
let depth = 0;
|
|
952
|
+
const start = i;
|
|
953
|
+
for (; i < code.length && i - start < 4000; i += 1) {
|
|
954
|
+
if (code[i] === "(") depth += 1;
|
|
955
|
+
else if (code[i] === ")") {
|
|
956
|
+
depth -= 1;
|
|
957
|
+
if (depth === 0) break;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
found.push(code.slice(start, i + 1));
|
|
961
|
+
}
|
|
962
|
+
return found;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* 이 텍스트가 IP 민감 호출을 하면서 `clientIp` 를 안 넘기는가. **호출 단위**로 본다(없으면 null).
|
|
967
|
+
*
|
|
968
|
+
* 종전엔 파일에 `clientIp` 가 한 번이라도 있으면 통과였다. 그래서 데이터 계층 모듈 하나에 주문·문의·조회를
|
|
969
|
+
* 함께 담는 흔한 배치에서, **올바른 호출 하나가 나머지 누락 전부를 침묵**시켰다(심의 실측 — 진짜 결함
|
|
970
|
+
* 2건이 통째로 가려졌다).
|
|
971
|
+
*
|
|
972
|
+
* 다만 호출 인자만 보면 **`access` 객체를 위에서 만들어 넘기는 정상 형태**가 걸린다(우리 프리셋이 그 형태다).
|
|
973
|
+
* 그래서 파일이 `clientIp` 를 담은 변수를 만들면 그 파일은 통과시킨다 — **오탐보다 미탐을 고르는 선택**이다.
|
|
974
|
+
* 이 규칙은 경고이고, 거짓 양성은 거래처 CI 를 노랗게 만들지만 거짓 음성은 우리가 못 잡을 뿐이다.
|
|
975
|
+
*/
|
|
976
|
+
function missingClientIp(rawWithBom, jsx = false) {
|
|
977
|
+
// ⚠ **BOM 을 지운다.** 1행이 client import 인 BOM 파일은 `^[ \t]*import` 앵커에 안 걸려 **그 파일의
|
|
978
|
+
// I2 가 통째로 스킵**됐다(심의 실측). 이미 두 번 닫은 "파일 통째 스킵"(접힌 import·블록 주석)과
|
|
979
|
+
// 같은 계열이다. 길이를 지키려고 지우지 않고 **공백으로 바꾼다** — 인자 슬라이스의 오프셋 때문이다.
|
|
980
|
+
const raw = rawWithBom.replace(/^\uFEFF/, " ");
|
|
981
|
+
// 값싼 사전 검사 — 클라이언트를 안 쓰는 파일(대다수)에 문자 배열 2벌을 물리지 않는다. 이 검사기는
|
|
982
|
+
// 고객 레포와 서빙 빌드에서도 돈다.
|
|
983
|
+
if (!/@(?:zalkera|oneque)\/client|lib\/(?:zalkera|oneque)/.test(raw)) return null;
|
|
984
|
+
const {masked: code, withStrings} = maskCode(raw, jsx);
|
|
985
|
+
if (!importsClientAtRuntime(withStrings)) return null;
|
|
986
|
+
// `clientIp` 를 담은 **객체 변수의 이름들**을 모은다(`const access = {phone, context: {clientIp}}`).
|
|
987
|
+
//
|
|
988
|
+
// ⚠ **이름을 모으는 이유.** 종전엔 그런 변수가 하나라도 있으면 **파일 전체**를 통과시켰다. 그래서
|
|
989
|
+
// 주문용 access 한 줄이 같은 모듈의 `submitInquiry`·`submitLead`·`recordPostView` 누락 셋을 통째로
|
|
990
|
+
// 침묵시켰다(심의 실측 — 경고 0건). 그건 "정상 호출 하나가 나머지를 가린다"는 앞선 차단이 고치려던
|
|
991
|
+
// 바로 그 형상이라, 완화가 개선을 되돌린 꼴이었다. 이제는 **그 변수를 실제로 넘기는 호출만** 봐준다.
|
|
992
|
+
//
|
|
993
|
+
// 객체 리터럴로 좁히는 것은 여전히 필요하다 — "선언문에 clientIp 가 있으면"으로 쓰면
|
|
994
|
+
// `export const inquiry = async (…) => zalkera.submitInquiry(i, {clientIp})` 같은 **함수 선언**까지
|
|
995
|
+
// 삼킨다(그 형상을 한 번 재현한 적이 있다).
|
|
996
|
+
//
|
|
997
|
+
// ⚠ 선언 키워드도 한 줄도 강제하지 않는다(심의 실측 오탐 2형상). `const access:\n OrderAccess = {…}`
|
|
998
|
+
// 처럼 prettier 가 **타입 주석을 접으면** waiver 를 잃었고 — import 절에서 고친 그 병이 여기 남아
|
|
999
|
+
// 있었다 — `(ip, access = {…clientIp…}) => …` 같은 **파라미터 기본값** access 도 못 봤다.
|
|
1000
|
+
// ⚠ **이름 앞도 잠근다.** 꼬리만 막았더니 **머리 절**(`(?::[^=;]…)?`)이 두 번째 폭주 교량이 됐다 —
|
|
1001
|
+
// `const opts = {timeout: 5000}` 다음 줄의 `const ctx = {clientIp}` 를 `timeout` 부터 한 매치로
|
|
1002
|
+
// 삼켜, 가짜 carrier `timeout` 이 서고 진짜 `ctx` 는 수집되지 않았다(오탐). 그 가짜 이름이 호출
|
|
1003
|
+
// 인자에 있으면 이번엔 진짜 누락이 침묵했다(미탐). **같은 정규식 안에서 같은 병이 재발**했고,
|
|
1004
|
+
// 그걸 "닫았다"고 적어 둔 것이 더 나빴다(심의 반려).
|
|
1005
|
+
//
|
|
1006
|
+
// 그래서 이름은 **선언 키워드 뒤**(`const|let|var`)이거나 **인자 경계 뒤**(`(`·`,` — 파라미터 기본값)
|
|
1007
|
+
// 여야 한다. ⚠ `}` 는 **뺐다** — 넣었더니 JSX 속성값(`<C note={{clientIp: ip}} />`)이 바인딩도
|
|
1008
|
+
// 아닌 이름을 carrier 로 세워 **진짜 누락을 침묵**시켰다(심의 실측). 얻는 것(`function h() {}` 뒤
|
|
1009
|
+
// 재대입 waiver)보다 잃는 것이 넓다. 그 재대입은 한계 목록에 적는다.
|
|
1010
|
+
// 객체 속성(`{timeout: …}`)·삼항의 뒷항·`obj.x =` 는 그 어느 쪽도 아니라 더는 안 걸린다.
|
|
1011
|
+
// ⚠ 꼬리는 **`}`·`;` 를 못 넘는다.** `[\s\S]` 로 두면 `const access = {phone: p};` 다음 줄의
|
|
1012
|
+
// `const ctx = {clientIp}` 까지 한 매치로 삼켜, ⑴ clientIp 가 **없는** access 가 carrier 로 인정되고
|
|
1013
|
+
// (미탐) ⑵ `matchAll` 이 겹침을 안 내므로 진짜 carrier 인 `ctx` 는 영영 수집되지 않는다(오탐).
|
|
1014
|
+
// 한 형상에서 양방향으로 틀렸다(심의 실측). 정상 형태 `{phone, context: {clientIp}}` 는 clientIp
|
|
1015
|
+
// 앞에 닫는 중괄호가 없어 그대로 산다.
|
|
1016
|
+
//
|
|
1017
|
+
// ⚠ **타입 주석 절도 `)` 를 못 넘는다.** 머리를 `[(,]` 로 잠갔더니 이번엔 그 절이 같은 교량을 다시
|
|
1018
|
+
// 열었다 — `function load(a, b: Foo) {}` 다음 줄의 `const ctx = {clientIp}` 를 `, b` 에서 시작해
|
|
1019
|
+
// `: Foo) {}\nconst ctx ` 를 타입으로 먹고 삼켰다. **4차·5차와 문자 하나까지 같은 실패 모드**이고
|
|
1020
|
+
// 양방향이다(가짜 carrier `b` 로 오탐, 그 이름이 인자에 있으면 미탐). 재현한 픽스처 하나만 닫고
|
|
1021
|
+
// "닫았다"고 쓴 것이 4차의 실수였는데 5차가 그대로 반복했다 — 그래서 이번엔 경계 문자를
|
|
1022
|
+
// **세 곳 전부** 같은 기준으로 박는다: 머리 `[(,;)]`, 타입절 `[^=;){}]`, 꼬리 `[^};]`.
|
|
1023
|
+
//
|
|
1024
|
+
// ⚠ 네 번째 형태도 여기서 닫힌다 — `const opts = {a: 1, timeout: 5000}` 은 콤마 뒤 속성이라 머리를
|
|
1025
|
+
// 통과했고, 타입절이 `}` 를 넘어 다음 줄 `const ctx = {clientIp}` 에 닿았다. **문자 클래스를 하나씩
|
|
1026
|
+
// 덧대는 방식이 네 라운드 연속 졌다**는 뜻이라, 이제는 "문장·객체 경계 문자는 어느 절도 못 넘는다"를
|
|
1027
|
+
// 규칙으로 적는다. 대가: 객체형 타입 주석(`const a: {x: string} = {…}`)의 waiver 를 잃는다(오탐 방향).
|
|
1028
|
+
// 잃는 것을 적어 두는 편이, 안 적고 다음 라운드에 또 같은 걸 맞는 것보다 낫다.
|
|
1029
|
+
//
|
|
1030
|
+
// ⚠ **다섯째 형태는 개행이었다.** 문자 클래스를 세 곳에 박고 "닫았다"고 썼는데, 타입절이 개행을
|
|
1031
|
+
// 배제하지 않아 초기화 없는 타입 선언(ASI 스타일)이 그대로 다리가 됐다:
|
|
1032
|
+
// `let queue: string[]` ⏎ `const ctx = {clientIp: ip}` → 가짜 carrier `queue`(오탐+미탐)
|
|
1033
|
+
// 그래서 개행은 **이어지는 줄이 들여쓰기돼 있고 문장 키워드로 시작하지 않을 때만** 허용한다.
|
|
1034
|
+
// 접힌 타입 주석(`const access:` ⏎ ` OrderAccess = {…}`)은 그 조건을 만족해 그대로 살고,
|
|
1035
|
+
// 다음 문장은 행머리이거나 키워드로 시작해 걸린다. 남는 구멍은 **들여쓴 키워드 없는 재대입**
|
|
1036
|
+
// (`\n ctx = {…}`)뿐이고, 그건 아래 한계 목록에 적는다.
|
|
1037
|
+
const STATEMENT_HEAD = "const|let|var|export|return|function|class|if|for|while|import|type|interface";
|
|
1038
|
+
//
|
|
1039
|
+
// ⚠ **교대문을 쓰지 마라.** 종전엔 개행 허용을 `(?:[^=;){}\n]|\n[ \t]+(?!…)){0,200}?` 로 썼는데,
|
|
1040
|
+
// 두 갈래가 **둘 다 공백을 먹어** 개행+들여쓰기 구간의 분할이 지수적으로 폭발했다(파국적 백트래킹).
|
|
1041
|
+
// **225바이트·6세그먼트**의 평범한 prettier 접힌 제네릭(`const x: Deep<⏎ A,⏎ B⏎> = build()`)에서 90초를
|
|
1042
|
+
// 넘겼다 — 적대 입력이 아니라 정상 코드다. 이 검사기는 고객 업로드 zip 과 서빙 게이트에서 돈다.
|
|
1043
|
+
// 스캔 상한(REGEX_SCAN_CAP)으로 막던 그 DoS 계열을, 그걸 막은 다음 라운드에 내가 더 나쁜 형태
|
|
1044
|
+
// (제곱 → 지수)로 되살렸다.
|
|
1045
|
+
//
|
|
1046
|
+
// 그래서 **접힘은 한 번만** 허용한다 — 두 개의 유계 런과 선택적 한 번의 개행뿐이라 모호성이 없다.
|
|
1047
|
+
// 대가: 두 번 이상 접힌 타입 주석은 waiver 를 잃는다(오탐 방향 · 아래 한계 목록).
|
|
1048
|
+
// 런 길이는 **200** 이다. 접힘을 한 번만 허용하면서 100 으로 줄였더니, 한 줄짜리 100~200자
|
|
1049
|
+
// 타입 주석(`const access: <120자> = {…}`)의 waiver 를 **말없이 잃었다**(심의 실측 오탐).
|
|
1050
|
+
// 유계 런 둘이라 200 이어도 모호성은 없다 — 줄인 이유가 없었다.
|
|
1051
|
+
const TYPE_RUN = "[^=;){}\\n]{0,200}";
|
|
1052
|
+
const CARRIER = new RegExp(
|
|
1053
|
+
`(?:(?:const|let|var)\\s+|[(,;)]\\s*)([\\w$]+)\\s*` +
|
|
1054
|
+
`(?::${TYPE_RUN}(?:\\n[ \\t]+(?![ \\t]*(?:${STATEMENT_HEAD})\\b)${TYPE_RUN})?)?` +
|
|
1055
|
+
`=\\s*\\{[^};]{0,200}?\\bclientIp\\b`,
|
|
1056
|
+
"g",
|
|
1057
|
+
);
|
|
1058
|
+
const carrierNames = new Set([...code.matchAll(CARRIER)].map((m) => m[1]));
|
|
1059
|
+
// ⚠ 이름에는 `$` 가 들어올 수 있다(`const $ctx = …`). 그러면 두 번 어긋난다 — 이스케이프를 안 하면
|
|
1060
|
+
// `$` 가 정규식 앵커가 되고, 이스케이프만 하면 이번엔 `\b` 가 성립하지 않는다(`$` 가 비단어 문자라
|
|
1061
|
+
// 공백 옆에서 경계가 안 생긴다). 그래서 경계를 **식별자 기준**으로 직접 쓴다(심의 실측 오탐).
|
|
1062
|
+
const carries = (argText) =>
|
|
1063
|
+
[...carrierNames].some((name) =>
|
|
1064
|
+
new RegExp(`(?<![\\w$])${name.replace(/[$]/g, "\\$&")}(?![\\w$])`).test(argText),
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const missing = I2_METHODS.filter((fn) => {
|
|
1068
|
+
const args = callArguments(raw, code, fn);
|
|
1069
|
+
if (args.length === 0) return false;
|
|
1070
|
+
return args.some((a) => !/\bclientIp\b/.test(a) && !carries(a));
|
|
1071
|
+
});
|
|
1072
|
+
return missing.length > 0 ? missing : null;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
485
1075
|
const STYLE_MODE = detectStyleMode(root);
|
|
486
1076
|
|
|
487
1077
|
/**
|
|
@@ -726,7 +1316,18 @@ function walk(dir) {
|
|
|
726
1316
|
const full = join(dir, name);
|
|
727
1317
|
if (statSync(full).isDirectory()) {
|
|
728
1318
|
walk(full);
|
|
729
|
-
|
|
1319
|
+
// ⚠ **관문 대가가 있다**(심의 실측): `.mjs` 를 훑으면서 `preview-runner --gate` 의 종료코드가
|
|
1320
|
+
// **0 → 1** 로 바뀐다(`[E3]` 예시 키 `oqsk_test_…` 를 오류층으로 잡는다). 실제 서빙 코퍼스는
|
|
1321
|
+
// rc=0 을 유지해 오늘의 폭발반경은 0 이지만, **발행이 남의 관문 계약을 바꾼다**는 사실은
|
|
1322
|
+
// 여기 적어 둔다. ⚠ 처방은 **면제 마커가 아니다**(설계 심의 DON'T-BUILD) — 시험 픽스처는
|
|
1323
|
+
// 접두 뒤 **7자 이하**를 쓰면 된다. E3 잣대가 `{8,}` 이고 실키는 43자라 7자는 **구성상
|
|
1324
|
+
// 실키일 수 없다**. 값 규약은 구성으로 면제하고 마커는 주장으로 면제한다 — 마커는 진짜
|
|
1325
|
+
// 키에도 똑같이 열리므로 E3 의 오류층 자격을 스스로 철회하는 셈이다.
|
|
1326
|
+
//
|
|
1327
|
+
// ⚠ `.mjs`·`.cjs` 도 훑는다. 종전엔 빠져 있어 **그 파일에서는 규칙이 아예 안 돌았다**
|
|
1328
|
+
// (심의 관찰 — 검사기가 조용한 것이지 안전한 것이 아니다). Next 설정·서버 유틸이 그 확장자로
|
|
1329
|
+
// 오는 레포가 있다.
|
|
1330
|
+
} else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name)) {
|
|
730
1331
|
check(full);
|
|
731
1332
|
if (isLayoutFile(full)) layoutFiles.push(full);
|
|
732
1333
|
} else if (name.endsWith(".css")) {
|
|
@@ -1396,6 +1997,22 @@ function check(file) {
|
|
|
1396
1997
|
`잘커라가 서빙하면 홉 1 이 구성상 참이라 기본값 그대로 맞습니다.`,
|
|
1397
1998
|
);
|
|
1398
1999
|
}
|
|
2000
|
+
|
|
2001
|
+
// I2: IP 민감 호출인데 `clientIp` 를 아예 안 넘긴다 — I1 의 반대편 끝이다(I 절 주석).
|
|
2002
|
+
// 여기도 **받아 줄 곳을 가리킨다**: 무엇을, 어떻게, 왜.
|
|
2003
|
+
// ⚠ `"use client"` 파일은 건너뛴다. 그런 파일이 클라이언트를 값으로 들여오는 것 자체가 E1·E2 **오류**이고,
|
|
2004
|
+
// 거기에 대고 `visitorIp(headers)` 를 처방하면 **브라우저에 없는 API** 를 가리키게 된다 — 오류 위에
|
|
2005
|
+
// 얹힌 틀린 경고는 사람을 잘못된 수선으로 보낸다(심의 관찰).
|
|
2006
|
+
const missing = isClient ? null : missingClientIp(src, holdsJsx(file, src));
|
|
2007
|
+
if (missing) {
|
|
2008
|
+
clientIpDeclarationSink().push(
|
|
2009
|
+
`[I2] ${rel}: ${missing.join("·")} 를 서버에서 부르는데 \`clientIp\` 선언이 없습니다 — ` +
|
|
2010
|
+
`백엔드가 보는 IP 가 **방문자가 아니라 이 서버**라 사이트 전체가 한 IP 로 뭉칩니다. ` +
|
|
2011
|
+
`@zalkera/client 의 \`visitorIp(headers)\` 로 뽑아 넘기세요 — ` +
|
|
2012
|
+
`**넘기는 자리가 시그니처 계열마다 다릅니다**: ${prescription(missing)}. ` +
|
|
2013
|
+
`문의·리드는 남의 제출이 내 429 가 되고, 조회수는 429 없이 집계만 조용히 접힙니다.`,
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
1399
2016
|
}
|
|
1400
2017
|
|
|
1401
2018
|
/**
|
|
@@ -1475,15 +2092,23 @@ function checkStyleWiring() {
|
|
|
1475
2092
|
* ⚠ **리터럴을 지운 소스로 리터럴 "값"을 재려 하지 마라.** 여기를 거치면 `sameSite: "strict"` 가
|
|
1476
2093
|
* `sameSite: " "` 가 된다 — 값 검사는 반드시 **원문**에 걸어야 한다(X3 가 그렇게 죽어 있었다).
|
|
1477
2094
|
*/
|
|
1478
|
-
function stripLiterals(code) {
|
|
2095
|
+
function stripLiterals(code, jsx = false) {
|
|
1479
2096
|
const out = code.split("");
|
|
1480
2097
|
const blank = (from, to) => {
|
|
1481
2098
|
for (let k = from; k < to && k < out.length; k++) if (out[k] !== "\n") out[k] = " ";
|
|
1482
2099
|
};
|
|
1483
2100
|
// `/` 가 정규식의 시작인지 나눗셈인지는 **직전 비공백 문자**로 가른다(표준 휴리스틱).
|
|
1484
|
-
//
|
|
1485
|
-
|
|
2101
|
+
//
|
|
2102
|
+
// ⚠ 집합은 [BEFORE_REGEX_LITERAL] **하나를 공유한다.** 종전엔 여기 사본이 따로 있었고, I2 쪽에서
|
|
2103
|
+
// JSX 때문에 `<` 를 빼는 동안 이쪽은 그대로여서 두 함수가 갈렸다 — 그리고 이 함수는 X1·X3 가
|
|
2104
|
+
// `.tsx` 를 읽는 자리라, `<div>{n}/월 {call(…)}</div>` 한 줄이 `</div>` 까지 통째로 공백이 돼
|
|
2105
|
+
// **가드 검사가 조용히 눈이 멀었다**(심의 실측). 사본이 갈리는 것 자체가 다음 덫이다.
|
|
2106
|
+
const BEFORE_REGEX = BEFORE_REGEX_LITERAL;
|
|
1486
2107
|
let prev = "";
|
|
2108
|
+
let prev2 = "";
|
|
2109
|
+
let word = "";
|
|
2110
|
+
let prevWord = "";
|
|
2111
|
+
let beforeWord = "";
|
|
1487
2112
|
for (let i = 0; i < code.length; i++) {
|
|
1488
2113
|
const c = code[i];
|
|
1489
2114
|
if (c === '"' || c === "'" || c === "`") {
|
|
@@ -1491,6 +2116,10 @@ function stripLiterals(code) {
|
|
|
1491
2116
|
for (i++; i < code.length; i++) {
|
|
1492
2117
|
if (code[i] === "\\") i++;
|
|
1493
2118
|
else if (code[i] === c) break;
|
|
2119
|
+
// ⚠ `'`·`"` 는 **개행을 못 넘는다**(형제 [maskCode] 와 같은 규칙). 종전엔 안 끊겨서,
|
|
2120
|
+
// 어긋난 따옴표 하나가 **파일 나머지를 통째로 공백**으로 만들었다 — X1 이 변이 핸들러를
|
|
2121
|
+
// 0건으로 보고 **가드 없는 POST 를 조용히 통과**시켰다(심의 실측). 한 줄 사고로 가둔다.
|
|
2122
|
+
else if (code[i] === "\n" && c !== "`") break;
|
|
1494
2123
|
}
|
|
1495
2124
|
blank(start + 1, i); // 따옴표는 남긴다 — 토큰 경계가 무너지지 않게.
|
|
1496
2125
|
prev = c;
|
|
@@ -1511,14 +2140,25 @@ function stripLiterals(code) {
|
|
|
1511
2140
|
blank(start, i + 1);
|
|
1512
2141
|
continue;
|
|
1513
2142
|
}
|
|
1514
|
-
|
|
2143
|
+
// `}` 뒤는 JSX 와 구분할 수 없어 나눗셈으로 본다([maskCode] 와 같은 판단).
|
|
2144
|
+
if (c === "/" && isRegexStart(prev, prev2, word || prevWord, beforeWord, code[i + 1], jsx)) {
|
|
1515
2145
|
// 정규식 리터럴. 줄바꿈을 못 넘으므로 **같은 줄 안에서만** 닫는 `/` 를 찾는다 —
|
|
1516
2146
|
// 판정이 틀려도 폭주가 한 줄로 제한된다. 문자 클래스 `[...]` 안의 `/` 는 종료가 아니다.
|
|
1517
2147
|
let j = i + 1;
|
|
1518
2148
|
let inClass = false;
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
2149
|
+
// ⚠ **상한**(`REGEX_SCAN_CAP`). 닫는 `/` 를 못 찾는 스캔은 줄 끝까지 가는데 실패해도 `i` 가
|
|
2150
|
+
// 안 나아가므로, 그런 후보가 한 줄에 많으면 비용이 파일 크기의 **제곱**이 된다 — 실측
|
|
2151
|
+
// 25KB 75ms · 50KB 267ms · 101KB 974ms · 203KB 3.5초(배증마다 4배). 이 함수의 소비처는
|
|
2152
|
+
// **오류층 X1** 이라 행중단이 곧 게이트 정지다. 상한을 넘는 리터럴은 나눗셈으로 분류된다
|
|
2153
|
+
// (세 레포 실측 최장 150자). I2 의 [maskCode] 와 같은 처방이다.
|
|
2154
|
+
for (; j < code.length && j - i < REGEX_SCAN_CAP && code[j] !== "\n"; j++) {
|
|
2155
|
+
// ⚠ 형제 [maskCode] 와 같은 규칙 — 이스케이프가 **개행을 못 건너뛴다**. 8차에 maskCode
|
|
2156
|
+
// 쪽만 고치고 여기를 빼놨더니, X1 소비처인 이 함수에서 팬텀 정규식이 다음 줄의
|
|
2157
|
+
// `export async function POST` 를 삼켜 **가드 없는 라우트가 조용히 통과**했다(심의 실측).
|
|
2158
|
+
if (code[j] === "\\") {
|
|
2159
|
+
if (code[j + 1] === "\n") break;
|
|
2160
|
+
j++;
|
|
2161
|
+
} else if (code[j] === "[") inClass = true;
|
|
1522
2162
|
else if (code[j] === "]") inClass = false;
|
|
1523
2163
|
else if (code[j] === "/" && !inClass) break;
|
|
1524
2164
|
}
|
|
@@ -1530,7 +2170,19 @@ function stripLiterals(code) {
|
|
|
1530
2170
|
}
|
|
1531
2171
|
// 같은 줄에서 안 닫혔다 = 정규식이 아니었다. 나눗셈으로 두고 지나간다.
|
|
1532
2172
|
}
|
|
1533
|
-
if (
|
|
2173
|
+
if (/[\w$]/.test(c)) {
|
|
2174
|
+
if (!word) beforeWord = prev;
|
|
2175
|
+
word += c;
|
|
2176
|
+
} else if (word) {
|
|
2177
|
+
prevWord = word;
|
|
2178
|
+
word = "";
|
|
2179
|
+
} else if (!/\s/.test(c)) {
|
|
2180
|
+
beforeWord = c;
|
|
2181
|
+
}
|
|
2182
|
+
if (!/\s/.test(c)) {
|
|
2183
|
+
prev2 = prev;
|
|
2184
|
+
prev = c;
|
|
2185
|
+
}
|
|
1534
2186
|
}
|
|
1535
2187
|
return out.join("");
|
|
1536
2188
|
}
|
|
@@ -1712,7 +2364,10 @@ function checkCrossOriginGuards() {
|
|
|
1712
2364
|
for (const e of entries) {
|
|
1713
2365
|
const full = join(dir, e.name);
|
|
1714
2366
|
if (e.isDirectory()) collect(full);
|
|
1715
|
-
|
|
2367
|
+
// ⚠ `route.js`·`.jsx`·`.mjs` 도 라우트다. 종전엔 `.ts`·`.tsx` 만 봐서 **가드 규칙(X1)이
|
|
2368
|
+
// 그 파일들에서 통째로 침묵**했다(I2 는 도는데 X1 만 안 도는 비대칭 · 심의 실측).
|
|
2369
|
+
// 구식인 Pages Router 쪽(X1p)이 오히려 `.js` 를 보고 있었다.
|
|
2370
|
+
else if (/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name)) routes.push(full);
|
|
1716
2371
|
}
|
|
1717
2372
|
};
|
|
1718
2373
|
// ⚠ `root` 는 **레포 루트가 아니라 소스 루트**(`./src`)다(82행). 초판은 `src/app/api` 도
|
|
@@ -1728,7 +2383,10 @@ function checkCrossOriginGuards() {
|
|
|
1728
2383
|
for (const file of routes) {
|
|
1729
2384
|
const code = readFileSync(file, "utf8");
|
|
1730
2385
|
const rel = relative(root, file);
|
|
1731
|
-
|
|
2386
|
+
// ⚠ **가드 축(X1·X1p)만 JSX 강등을 끈다.** JSX 파일에서 낱말 뒤 정규식을 나눗셈으로 두면
|
|
2387
|
+
// 그 안의 백틱이 살아나 파일을 삼켜 **가드가 안 보인다**(미탐). 산문 삼킴은 I2·X3 축에만
|
|
2388
|
+
// 해로우므로, 두 축을 갈라 각각에 맞는 사본을 준다(심의 처방 · 미탐 4·오탐 2 동시 해소).
|
|
2389
|
+
const handlers = findMutationHandlers(stripLiterals(code, false));
|
|
1732
2390
|
if (!handlers.length) continue;
|
|
1733
2391
|
|
|
1734
2392
|
// 면제 마커는 **파일 상단**에만 둔다 — 아무 데나 허용하면 주석·문자열 안의 한 줄로
|
|
@@ -1801,7 +2459,7 @@ function checkCrossOriginGuards() {
|
|
|
1801
2459
|
let usesStateCookie = false;
|
|
1802
2460
|
for (const file of sources) {
|
|
1803
2461
|
const raw = readFileSync(file, "utf8");
|
|
1804
|
-
const src = stripLiterals(raw);
|
|
2462
|
+
const src = stripLiterals(raw, holdsJsx(file, raw)); // X3 — 산문 삼킴을 막아야 하는 축
|
|
1805
2463
|
if (/\bconsumeOAuthState\s*\(/.test(src)) usesStateCookie = true;
|
|
1806
2464
|
// 정의 형태를 묻지 않는다 — function 선언·화살표 상수 둘 다.
|
|
1807
2465
|
const def = src.match(/(?:function\s+consumeOAuthState\b|consumeOAuthState\s*[:=][^=]*=>)/);
|
|
@@ -1930,7 +2588,7 @@ function checkPagesRouter() {
|
|
|
1930
2588
|
|
|
1931
2589
|
if (isApi(file)) {
|
|
1932
2590
|
// ── X1p — 가드 **유무**만. 위치는 못 잰다(위 주석).
|
|
1933
|
-
const code = stripLiterals(raw);
|
|
2591
|
+
const code = stripLiterals(raw, false); // X1p: 같은 이유
|
|
1934
2592
|
// 파일 상단 마커로 면제. X1 과 같은 마커·같은 "첫 export 앞까지" 규칙.
|
|
1935
2593
|
const head = code.slice(0, code.search(/^export\b/m) + 1 || code.length);
|
|
1936
2594
|
if (/\/\/\s*zalkera-allow-cross-origin:/.test(raw.slice(0, head.length || raw.length))) continue;
|
package/llms.txt
CHANGED
|
@@ -691,7 +691,19 @@ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: numb
|
|
|
691
691
|
CSS 변수 주입(`style={{"--...": ...}}`)에만 허용된다.
|
|
692
692
|
- ❌ **`var(--oneq-*)` 참조**(정의처 없는 죽은 레거시 토큰). ✅ 테넌트 색은 토큰 유틸리티(`bg-primary`·
|
|
693
693
|
`text-primary` 등, §8)로 쓴다.
|
|
694
|
-
- ❌ 서버에서 `submitInquiry`/`submitLead`/`recordPostView` 부를 때 clientIp
|
|
694
|
+
- ❌ 서버에서 `submitInquiry`/`submitLead`/`recordPostView` 부를 때 clientIp 누락. **축마다 결과가 다르다** —
|
|
695
|
+
`submitInquiry`/`submitLead` 는 **레이트리밋**(테넌트×IP·전 호출 계수 · 문의 3건/60초·리드 30건/60초)이라
|
|
696
|
+
사이트 전체가 한 IP 로 뭉쳐 남의 제출이 내 429 가 된다. `recordPostView` 는 레이트리밋이 아니라
|
|
697
|
+
**조회 dedup**(`sha256(IP|User-Agent)` · 게시글별 · UTC 달력 하루)이라, 뭉치면 **게시글마다 하루 한 건**으로
|
|
698
|
+
접힌다(서빙 인스턴스 수만큼) — 429 가 아니라 집계가 죽는다. ⚠ 그 UA 는 **방문자 것이 아니다**: 서버가
|
|
699
|
+
백엔드를 부르므로 브라우저 UA 는 전달되지 않고 Node fetch 기본값은 상수 `"node"` 다.
|
|
700
|
+
✅ `visitorIp(req.headers)` 로 뽑아 넘긴다. **넘기는 자리가 둘로 갈린다**: 문의·리드·조회는 두 번째 인자가
|
|
701
|
+
곧 컨텍스트라 `(input, {clientIp})`, 주문 계열은 access 안이라 `(no, {phone, context: {clientIp}})` 다.
|
|
702
|
+
⚠ **`confirmPayment` 만 access 가 세 번째**다 — `(no, providerParams, {phone, context: {clientIp}})`.
|
|
703
|
+
두 번째에 넣으면 access 가 `providerParams` 자리에 앉아 헤더가 안 붙는데 인자엔 `clientIp` 가 생겨
|
|
704
|
+
검사기까지 침묵한다.
|
|
705
|
+
틀린 쪽을 쓰면 TS 는 잡지만 **JS 레포는 조용히 통과**한다(헤더가 안 붙는데 파일엔 `clientIp` 가 생긴다).
|
|
706
|
+
검사기(`zalkera-validate`)가 이 형상을 `[I2]` 경고로 잡는다.
|
|
695
707
|
- ❌ **`x-forwarded-for` 첫 엔트리를 clientIp 로 쓰기**(`xff.split(",")[0]`). 첫 엔트리는 **방문자가 요청에 손으로
|
|
696
708
|
실은 값**이라 IP 레이트리밋이 한 줄로 우회되고 IP 기록이 오염된다(실측). ✅ `visitorIp(req.headers)` 를 쓰고
|
|
697
709
|
프록시 홉 수를 선언한다(§2). `x-real-ip` 폴백도 쓰지 마라 — 세우는 주체가 프록시마다 다르고 안 세우면 위조 자유다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalkera/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Credium Co., Ltd.",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit",
|
|
49
49
|
"test": "vitest run",
|
|
50
50
|
"test:watch": "vitest",
|
|
51
|
-
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
51
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run check:published && npm run build",
|
|
52
|
+
"check:published": "node scripts/check-published-drift.mjs"
|
|
52
53
|
},
|
|
53
54
|
"engines": {
|
|
54
55
|
"node": ">=18"
|