3개의 AI 에이전트 코드베이스를 스캔하여 보호되지 않은 도구 호출(tool calls)에서 발견한 것들
요약
세 개의 오픈 소스 TypeScript AI 에이전트 코드베이스를 정적 분석한 결과, 도구 호출의 83%가 보호 장치 없이 노출되어 있음을 발견했습니다. LLM이 결정하는 함수 호출은 UI 기반의 기존 보안 방식으로는 방어할 수 없으므로, 코드 레벨에서의 직접적인 제어 장치가 필수적임을 강조합니다.
핵심 포인트
- 분석된 도구 호출 중 83%가 검증이나 인증 없이 실행됨
- LLM은 비즈니스 규칙을 모르므로 코드 내 직접적인 가드 필요
- 정적 분석기 diplomat-agent-ts를 통한 취약점 인벤토리 조사
- UI가 없는 에이전트 환경에서는 함수 호출 옆에 보안 로직 배치 필수
데이터베이스에 쓰거나, 파일을 삭제하거나, 카드를 결제하거나, 서브프로세스(subprocess)를 생성하거나, 다른 에이전트에게 제어권을 넘길 수 있는 669개의 함수가 있었습니다.
그중 553개에는 어떤 종류의 보호 장치(guard)도 없었습니다. 입력 검증(input validation), 인증 확인(auth check), 속도 제한(rate limit), 확인 단계(confirmation step)가 전혀 없었습니다. 모델의 결정과 부수 효과(side effect) 사이에 아무것도 없었습니다.
이는 83%에 달합니다. 단 하나도 확인 절차를 거치지 않았습니다.
저는 세 개의 오픈 소스 TypeScript AI 에이전트 코드베이스에 정적 분석기(static analyzer)를 적용하여 숫자를 세는 방식으로 이 수치를 얻었습니다. 침투 테스트(pen test)가 아닙니다. CVE(취약점) 탐색도 아닙니다. 각 에이전트가 _할 수 있는 것_과 그 기능 중 어떤 것에 코드상 제어 장치가 있는지를 파악한 인벤토리(inventory) 조사입니다.
이 글에서는 방법론, 전체 표, 그리고 — 제가 가장 중요하게 생각하는 부분인 — 이 결과를 신뢰하기 전에 제거해야 했던 오탐(false positives)에 대해 다룹니다.
에이전트에서 보호되지 않은 도구 호출(tool call)이 별개의 문제인 이유
일반적인 웹 애플리케이션에서는 사람이 버튼을 클릭합니다. 부수 효과로 이어지는 경로는 양식(form), 검증 레이어(validation layer), 확인 대화 상자(confirmation dialog), 세션 속도 제한(session rate limit)을 거칩니다. 위험한 호출은 누군가가 의도적으로 설계한 UI와 미들웨어(middleware)로 감싸져 있습니다.
에이전트에서는 LLM(대규모 언어 모델)이 어떤 함수를 호출할지, 어떤 인자(arguments)를 사용할지, 몇 번이나 호출할지를 결정합니다. LLM은 귀하의 비즈니스 규칙을 알지 못합니다. 루프를 돌거나, 인자를 환각(hallucinate)하거나, 도구 결과에 삽입된 텍스트에 의해 설득당할 수도 있습니다.
따라서 보호 장치는 더 이상 UI에 존재할 수 없습니다. UI가 없기 때문입니다. 보호 장치는 코드 내에서, 호출 바로 옆에 존재해야 합니다.
흥미로운 질문은 더 이상 "이 앱이 안전한가"가 아닙니다. 그것은 바로 다음과 같습니다: 모델이 도달할 수 있는 실제 동작을 수행하는 모든 함수에 대해, 코드상에 제어 장치가 있는가 — 만약 없다면, 당신은 그 사실을 알고 있는가?
대부분의 팀은 알지 못합니다. 그들이 부주의해서가 아니라, 아무도 인벤토리를 가지고 있지 않기 때문입니다. 볼 수 없는 것은 검토할 수 없습니다.
실제로 측정한 것
저는 ts-morph(TypeScript 컴파일러 API)를 기반으로 구축된 정적 스캐너인 diplomat-agent-ts를 작성했습니다. 이 스캐너는 AST(Abstract Syntax Tree, 추상 구문 트리)를 탐색하여 부작용(side-effect) 패턴 카탈로그와 일치하는 호출 표현식(call expressions)을 찾아내고, 각 호출이 동일한 함수 내에 가드(guard)를 가지고 있는지 확인합니다. 두 개의 런타임 의존성(runtime dependencies)만 필요하며, 설정 파일이 없고, 7,874개의 파일로 구성된 코드베이스에서 약 9초가 소요됩니다.
여기서 **도구 호출 (tool call)**이란 12개의 부작용 카테고리에 걸쳐 40개 이상의 패턴 중 하나와 일치하는 모든 호출을 의미합니다:
payment · database_write · database_delete · http_write · email · messaging · agent_invocation · llm_call · publish · dynamic_code · file_delete · destructive
**가드 (guard)**는 스캐너가 구문론적으로 확인할 수 있는 파일 내 제어 장치입니다: 입력 검증 (Zod, Yup, class-validator), 속도 제한 (rate limit, @Throttle 데코레이터 또는 커스텀 리미터), 인증 확인 (auth check), 확인 단계 (confirmation step), 멱등성 키 (idempotency key), 재시도 범위 (retry bound) 등이 포함됩니다.
각 호출은 다음 세 가지 상태 중 하나에 해당합니다:
no_checks— 가드가 전혀 없는 부작용partial_checks— 어느 정도의 커버리지는 있으나, 예상되는 제어 장치가 최소 하나 이상 누락된 상태confirmed—// checked:ok주석을 통해 명시적으로 확인된 상태
수치를 해석할 때 중요한 점을 미리 하나 짚고 넘어가겠습니다. confirmed 상태는 이 스캐너만의 고유한 관례인 주석을 필요로 합니다. 조사 대상인 세 프로젝트 중 그 어느 곳도 이 관례를 들어본 적이 없습니다. 따라서 모든 외부 코드베이스는 구조적으로 confirmed가 0으로 나타납니다. 이 수치는 비난을 위한 것이 아니라, 최저 기준선(floor)을 의미합니다.
저는 고정된 커밋(pinned commit)의 수정되지 않은 공개 클론(public clone)을 대상으로 각 스캔을 실행했으므로, 결과는 정확히 재현됩니다. 모든 명령어는 리포지토리의 MANIFEST.md에 명시되어 있습니다.
그리고 이 모든 것을 관통하는 프레임워크는 다음과 같습니다: 이것은 점수가 아니라 인벤토리(inventory, 목록)입니다. 높은 no_checks 수치는 성적이 아니라, 어디를 살펴봐야 하는지를 알려주는 지도입니다.
수치
세 개의 코드베이스, 네 개의 범위(OpenAI의 프레임워크 패키지는 동작 방식이 다르기 때문에 예제와 분리했습니다).
| 코드베이스 (범위) | 유형 | TS 파일 수 | 도구 호출 (Tool calls) | no_checks | partial |
|---|---|---|---|---|---|
OpenClaw (src/) | 애플리케이션 (Application) | 7,874 | 419 | 332 (79%) | 87 |
| ... | |||||
위에서 언급한 이유로 인해 모든 범위에서 confirmed는 0입니다. |
83%라는 수치가 헤드라인을 장식하지만, 실제로는 그 분포가 더 정직한 이야기를 들려줍니다. 세트 중에서 가장 군더더기 없고 의도적으로 구축된 코드베이스인 OpenAI의 프레임워크 패키지조차 no_checks가 94%로 나타났습니다. 이는 OpenAI 팀이 부주의해서가 아닙니다. 가드(guards)가 정적 스캐너(static scanner)가 살펴보는 위치에 대부분 존재하지 않기 때문입니다. 가드는 미들웨어(middleware), 게이트웨이(gateway), 또는 프레임워크가 사용자가 연결할 것이라고 예상하는 런타임(runtime)에 존재합니다. 스캐너는 호출 지점(call site)을 볼 뿐, 배포(deployment) 환경을 보지는 못합니다.
이것이 바로 핵심입니다. "모델이 도달할 수 있는 것"과 "가시적인 제어(visible control)가 있는 것" 사이의 간극은 이 모든 리포지토리(repo)에서 실제로 존재합니다. 수치는 단지 이를 계산 가능하게 만들 뿐입니다.
카테고리가 드러내는 것
네 가지 범위 전체에서 카테고리별로 부작용(side effects)을 집계하면 다음과 같습니다 (단일 호출이 하나 이상의 부작용을 가질 수 있습니다):
| 카테고리 | 발생 횟수 |
|---|---|
destructive (subprocess / shell) | 486 |
| ... | |
코드베이스의 유형에 따라 형태가 달라집니다. 애플리케이션(OpenClaw)은 destructive와 file_delete가 지배적입니다. 이는 명령을 실행하고 파일을 관리하는 도구이므로, "도구 호출"의 거대한 비중은 버그가 아니라 제품의 기능 그 자체입니다. 프레임워크는 publish와 agent_invocation 쪽으로 기울어 있습니다. 프레임워크는 다른 에이전트에게 제어권을 넘기고 결과물(artifacts)을 전송하며, 이것이 프레임워크가 하는 일입니다. |
불편한 부분은 제가 직접 말씀드리겠습니다. destructive는 가장 큰 카테고리인 동시에 "음, 그건 말 그대로 앱의 역할이잖아"라고 말하기 가장 쉬운 카테고리이기도 합니다. 셸 러너(shell runner)는 셸을 실행합니다. 그 안의 모든 execSync를 플래그(flagging)하는 것은 기술적으로는 옳지만 문맥상으로는 당연한 일입니다. 그렇기 때문에 이 출력 결과는 맹목적으로 조치를 취해야 하는 판결문이 아니라, 우선순위를 정해 분류해야 하는 인벤토리(inventory)인 것입니다.
거버넌스 측면에서는 모든 발견 사항에 OWASP Agentic 코드가 태그로 지정됩니다. 분포는 다음과 같습니다: ASI-02 (도구 오용 (tool misuse), 기본 태그)는 669개 전체에서 발생했습니다; ASI-01 (과도한 에이전시 (excessive agency) — 인증 확인이 없는 부작용)은 576개; ASI-03 (권한 침해 (privilege compromise) — 확인 절차가 없는 고위험 작업)은 465개입니다. 런타임 전용 코드(공급망 (supply chain), 정렬 불량 (misalignment), 기만 (deception))는 정적 분석 (static analysis)의 범위를 의도적으로 벗어나 있습니다.
어려운 점은 부작용을 찾는 것이 아니었습니다. 그것을 과다 집계하지 않는 것이었습니다.
누구나 .delete(나 exec(를 grep으로 검색할 수 있습니다. 그렇게 하면 5분 만에 숫자를 얻을 수 있지만, 그 숫자는 쓰레기(garbage)입니다. 진짜 작업은 그 숫자가 쓰레기가 되지 않게 만드는 것입니다.
이 과정을 정직하게 유지하는 설계 규칙은 다음과 같습니다: 패턴은 데이터이며, 매처 (matcher)는 의도적으로 멍청하게 유지한다. 실제 상황에서 오탐 (false positive)이 발생하면, 매칭 로직을 수정하는 것이 아니라 패턴 카탈로그를 수정합니다. 아래의 모든 수정 사항은 휴리스틱 (heuristic)을 미세 조정하는 것이 아니라, 회귀 테스트 (regression test)를 포함한 커밋 (commit)입니다.
중요했던 네 가지 사례:
regex.exec()는 서브프로세스 (subprocess)가 아닙니다. destructive 카테고리는 child_process를 임포트(import)한 모든 파일 내의 exec를 포착했습니다. 여기에는 /^extensions\/([^/]+)\//.exec(path)와 같은 인라인 리터럴 (inline literals)에 대한 RegExp.prototype.exec()도 포함되었습니다. 이는 단순한 문자열 파싱 (string parsing)임에도 쉘 스폰 (shell spawn)으로 분류되었습니다. 근본 원인은 AST 추출 (AST extraction)에 있었습니다. 정규식 리터럴 수신자 (regex-literal receiver)가 누락되어 exec()와 구별할 수 없는 순수한 exec 이름이 생성되었습니다. RegularExpressionLiteral 케이스를 추가함으로써 OpenClaw의 발견 사항을 17개 줄였고, 보고서에서 정당하게 무해한 6개의 파싱 함수를 제거했습니다.
sandbox에 db가 포함되어 있습니다. 초기 database_delete 패턴은 db라는 이름의 객체와 일치했습니다. 문자열 sandbox에는 d-b라는 부분 문자열 (substring)이 포함되어 있어 (san-db-ox), SANDBOX_BACKEND_FACTORIES.delete()가 데이터베이스 삭제로 기록되었습니다. 짧고 일반적인 이름에 대한 부분 문자열 매칭은 근본적으로 취약합니다. 해결책: 정식 수신자 이름(prisma) 또는 실제 drizzle-orm 임포트를 요구하도록 수정했습니다.
deploy는 다른 단어 안에 포함되어 있는 동사입니다. nameContains: ["deploy"]를 매칭하면 Mastra의 deployer 패키지 전반에서 cancelDeploy, getDeployStatus, listDeployments와 같은 항목들이 탐지되었습니다. 이들은 게시(publish)와 관련된 부작용(side effects)이 아니라 쿼리 및 관리 작업입니다. 단독 deploy() 호출에 대한 정확한 일치(exact match) 방식으로 전환함으로써, 단 한 번의 커밋으로 39개의 오탐(false positives, FP)을 제거했습니다. 수동 감사 결과, 샘플링된 10개 모두가 실제 오탐임을 확인했습니다.
client.messages.create()는 Twilio가 아니라 Anthropic입니다. 메서드 이름은 같지만, 발생하는 부작용(side effect)은 완전히 다릅니다. 이것이 모호한 패턴에 importContains 조건이 포함되는 이유입니다. 즉, 해당 패턴은 해당 파일을 구분해 주는 패키지가 임포트(import)된 경우에만 실행됩니다. 패턴 테이블의 순서는 우선순위를 인코딩합니다. 결제(payments)를 가장 먼저, 그 다음은 데이터베이스 쓰기(database writes) 이전에 LLM 호출(LLM calls)이 오도록 배치하여, client.chat.completions.create()가 데이터베이스 쓰기로 잘못 분류되는 일이 없도록 합니다.
저는 사과해야 하는 471개의 결과보다, 방어할 수 있는 419개의 결과를 보고하는 쪽을 택하겠습니다. OpenClaw에 대한 검증 단계는 샘플링 감사에서 30%의 오탐률로 시작되었습니다. 그 오탐률을 없애는 것이 바로 실제 제품의 가치입니다.
이것이 알려주지 않는 것들
기술적인 독자라면 어차피 찾아낼 것이기에, 솔직한 한계점들을 밝힙니다:
- 보호되지 않았다고 해서 취약한 것은 아닙니다 (Unguarded is not the same as vulnerable). 플래그가 지정된 호출이 완전히 안전할 수도 있습니다. 보호 로직(guard)이 미들웨어(middleware), 게이트웨이(gateway), 또는 스캐너가 볼 수 없는 계층에 존재할 수 있기 때문입니다. 출력 결과는 무엇이 고장 났는지가 아니라, 어디를 살펴봐야 하는지를 알려줍니다.
- 정적 분석(Static analysis) 전용입니다. 런타임 탐지(Runtime detection) 기능은 없습니다. 만약 보호 기능이 파일 외부에서 강제된다면, 사용자가 주석(annotate)을 달지 않는 한 스캐너는 이를 알 수 없습니다.
- 함수 내 분석(Intra-procedural) 방식입니다. 보호 탐지는 동일한 함수와 그 즉각적인 데코레이터(decorators)를 확인합니다. 다른 파일의 3개 호출 프레임(call-frames) 떨어진 곳에 있는 보호 로직은 인정되지 않습니다. 함수 간 분석(Cross-function analysis)은 현재의 주장 사항이 아닌, 다음 단계의 이정표입니다.
- ORM 패턴을 위해서는 임포트(import)가 필요합니다. Mongoose, Sequelize, TypeORM은 일반적인 메서드 이름(
.save(),.create())을 사용하므로
// checked:ok — middleware/approval.ts에 의해 보호됨
export async function chargeCustomer(amount: number, customerId: string) {
return stripe.charges.create({ amount, currency: "usd", customer: customerId });
}...
그리고 보호되지 않은 새로운 호출이 빌드(build)를 실패하게 만들려면:
- name: Diplomat governance scan
run: npx -y @diplomat-ai/diplomat-agent-ts scan . --fail-on-unchecked
이 스캐너(scanner)는 Apache-2.0 라이선스이며, 두 개의 의존성(dependencies)을 가진 TypeScript 전용 프로젝트입니다. 위의 벤치마크(benchmark) 결과물은 고정된 커밋(pinned commits)에서 정확히 재현됩니다. 모든 명령은 리포지토리(repo)에 포함되어 있습니다.
리포지토리 및 재현 가능한 벤치마크: github.com/Diplomat-ai/diplomat-agent-ts
지난주에 배포한 것이 무엇이든 그 위에서 실행해 보세요. 83%라는 수치는 제가 작성하지 않은 세 개의 코드베이스(codebases)를 대상으로 한 결과였습니다. 제가 직접 작성한 코드베이스들에 대해서는 어떤 결과가 나올지 더 궁금합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기