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