본문으로 건너뛰기
강홍재/ James
← Work
Live2026 · QA Engineer · OSS Author· Started(First Commit date)

Adversary

스키마 하나에서 "설명이 붙은" 악성 테스트 입력을 뽑아내는 오픈소스 라이브러리 - 경계값·i18n·인젝션, 각 입력이 왜 깨질 수 있는지까지.

  • TypeScript
  • Zod
  • OpenAPI
  • Open Source
  • QA
스크린샷
  • Zod 스키마 하나를 "설명이 붙은" 적대적 입력으로 바꾸는 CLI - 각 값에 기법·이유 라벨이 붙는다.

  • OpenAPI 스펙에서 엔드포인트별 적대적 입력 리포트를 뽑는다 - Zod 스키마 없이, QA가 스펙만으로.

  • 실제 FastAPI 프로젝트(frameboard)의 Pydantic 모델에 적용 - model_json_schema()로 뽑은 JSON Schema를 그대로 adversary에.

Setup

Problem

입력 검증 코드를 짜는 사람 대부분은 경계값·유니코드·인젝션 케이스를 손으로 다 떠올리지 못한다. z.string().min(3) 한 줄을 쓰고 나면, 그 필드가 실제로 받아낼 악성 입력 - 3자보다 한 글자 짧은 값, 11개 UTF-16 코드유닛짜리 가족 이모지, javascript:로 시작하는 URL - 을 일일이 테이블로 만드는 건 별개의 일이고, 대개 안 만든다. 그 지식은 보통 i18n·보안·QA를 오래 한 사람의 머릿속에만 있다. 그래서 검증은 "통과했으니 안전하다"는 착각을 만들고, 정작 위험한 값은 스키마를 그대로 빠져나간다.

Context

14년 QA에서 가장 반복적으로 쓴 게 경계값 분석(BVA)·동등 분할(EP), 그리고 "이 필드가 무엇을 받아낼 것인가"라는 질문이었다. 그 판단을 매번 손으로 하지 않고 스키마에서 자동으로 뽑아낼 수 있으면, QA 지식이 없는 개발자도 같은 방어선을 CI에 깔 수 있다고 봤다. AI로 생성하지 않는다는 게 설계의 핵심 제약이었다 - 실패 가설이 그럴듯하지만 틀리면 도구 전체의 신뢰가 무너진다. 그래서 모든 설명은 손으로 큐레이션하고, 각 설명이 기대는 사실('ß'.toUpperCase()가 'SS', z.url()이 정말 javascript:를 통과시킴)마다 그것을 검증하는 테스트를 붙였다. 틀린 설명은 CI에서 깨진다.

Users

Zod로 외부 입력(폼 바디·API 요청·웹훅·설정 파일)을 검증하는 개발자, 그리고 Zod 스키마 없이 OpenAPI 스펙만 가진 QA 엔지니어.

Hypothesis

경계값·i18n·인젝션 카탈로그를 스키마에서 자동으로 뽑고, 각 값에 "어떤 기법이 만들었고 왜 깨질 수 있는지"를 붙이면, i18n·보안 전문가가 아니어도 적대적 입력 테스트를 CI 한 줄로 깔 수 있다.

Build

What I did
  • 스키마에서 설명 붙은 적대적 입력 생성 - 각 값에 technique(BVA·EP·i18n·injection)·family·failureHypothesis·validity 라벨
  • 4개 기법 카탈로그 - 경계값 분석·동등 분할·i18n/유니코드(정규화·grapheme vs code unit·bidi·homoglyph)·인젝션(SQL·XSS·SSTI·CSV 수식·명령어·경로 순회·CRLF·NUL)
  • 타입별 커버리지 - string·number/integer·boolean·enum/literal·array·date/date-time·union, 각 타입의 함정에 맞춘 생성기
  • format-aware 팩 - email·url·uuid·hostname·ipv4·ipv6·base64, 해당 파서를 겨냥한 추가 입력. z.url() 검증을 통과하지만 위험한 값을 포함
  • 3개 진입점 - Zod v4·plain JSON Schema·OpenAPI 3.x. 스펙만 있으면 코드 없이 엔드포인트별 리포트
  • CLI + Markdown 리스크 리포트(인젝션 우선 정렬), 무의존성·MIT로 npm 배포, 모든 실패 가설을 검증하는 테스트 스위트
Product decisions
  • AI 없이 손으로 큐레이션 - 런타임 생성은 그럴듯한 거짓 설명을 만든다. 결정론·오프라인·API 키 없음이 신뢰의 조건
  • 스캐너가 아니라 데이터 생성기 - 코드를 실행하거나 버그를 단정하지 않고, 적대적 입력을 plain 배열로 넘긴다. 판정은 사용자 assertion 몫
  • OpenAPI-first 진입점 - Zod 스키마가 없는 QA도 스펙만으로 엔드포인트별 악성 입력 리포트를 받게
  • 모든 실패 가설에 검증 테스트 - 과장하거나 틀린 설명은 CI에서 깨지게 만들어, 카탈로그가 커져도 신뢰가 유지되도록
QA considerations
  • 생성된 값이 스키마 자신의 제약을 만족하는지(validity: valid/invalid/unknown)를 정확히 라벨링하는가 - i18n·injection은 "통과 여부가 곧 테스트 대상"이라 unknown으로 정직하게 둠
  • 각 failureHypothesis가 기대는 사실이 실제로 참인가 - 217개 테스트가 'ß'.toUpperCase()는 'SS', 가족 이모지는 11 코드유닛, z.url()이 javascript: 통과 같은 사실을 하나씩 고정
  • JSON이 표현 못 하는 값(absent·NaN·Infinity·-0)이 출력에서 조용히 사라지지 않는가 - 리터럴 문자열/null로 인코딩해 무손실
  • Markdown 리포트에서 비가시·bidi 문자가 출력을 오염시키지 않는가 - 비ASCII를 \uXXXX로 이스케이프
  • public API(adversary·fromJsonSchema·fromOpenApi·toMarkdown·catalog·packs·Fixture)가 1.0부터 semver로 고정되어 회귀 없이 카탈로그만 성장하는가

Outcome

Metrics

npm에 adversary@1.1.0으로 라이브(MIT·런타임 무의존성). 13개 파일 217개 테스트·CI 그린. Zod v4·JSON Schema·OpenAPI 3.x 3개 진입점, 7개 format 팩.

Result / Learning

"검증은 안전이 아니다"를 도구로 증명하는 게 가장 분명한 결과였다. z.url()에 javascript:·file://·클라우드 메타데이터 SSRF 주소를 넣으면 전부 safeParse를 통과한다 - 검증을 통과한 값이 그대로 위험하다는 걸 눈앞에서 보여주면, 필드 뒤의 코드가 진짜 방어선이라는 게 설명 없이 전달된다. QA 지식을 코드로 옮기며 배운 건, "악성 입력 목록"보다 "왜 깨지는지의 설명"이 훨씬 어렵고 훨씬 가치 있다는 것. 값은 복사할 수 있어도 이유는 검증해야 하고, 그 검증을 테스트로 고정한 게 이 도구가 naughty-strings 리스트와 다른 지점이다.

Retrospective
  • 처음엔 "악성 입력을 많이 만드는 것"이 핵심이라 생각했지만, 실제 가치는 각 입력에 붙는 "검증된 설명"에 있었다. 값은 흔하고, 신뢰할 수 있는 이유가 희소하다.
  • 복합 타입(중첩 객체·record·intersection)은 아직 최상위 presence 프로브로만 다룬다 - 깊은 중첩 하강은 로드맵으로 남겨, 지금 커버하는 범위를 정직하게 표시했다.
Tech stack
  • TypeScript
  • Zod v4
  • JSON Schema
  • OpenAPI 3.x
  • vitest
  • tsup
  • npm