lifecarelog
백엔드

LLM 출력은 shell 문자열 아닌 argv로: execFile 배열과 allowlist

에이전트가 만든 값을 exec 문자열에 붙이면 명령 인젠션이 열려요. execFile 배열, verb allowlist, 인자 정규식으로 막는 백엔드 래퍼를 정리했어요.

4분 읽기

에이전트가 뽑아낸 브랜치명을 그대로 exec("git checkout " + name)에 넣는 코드를 저도 한 번 짰어요. 그날은 잘 돌았고, 며칠 뒤 로그에서 ; rm -rf가 섞인 값을 보고서야 문제를 알았어요. 값은 LLM이 만들었지만 실행은 제 서버 권한으로 된다는 게 문제의 본질이에요.

문자열이 아니라 배열이어야 하는 이유

exec는 문자열을 셸에 넘겨요. 그래서 ;, |, $( )가 전부 문법으로 해석돼요. 반면 execFile과 spawn은 인자를 배열로 받고 shell 옵션 기본값이 false라서 셸을 거치지 않아요. 값에 ;가 들어 있어도 그냥 글자 하나일 뿐이에요.

직접 바꿔 보니 diff는 15줄 안이었어요.

import { execFile } from "node:child_process";
 
const ALLOWED = new Set(["status", "log", "diff"]); // verb allowlist
 
export function runGit(verb, args) {
  if (!ALLOWED.has(verb)) throw new Error(`blocked verb: ${verb}`);
  for (const a of args) {
    if (!/^[\w./-]{1,128}$/.test(a)) throw new Error(`bad arg: ${a}`);
  }
  return new Promise((ok, no) =>
    execFile("git", [verb, ...args], { timeout: 10_000 }, (e, out) => (e ? no(e) : ok(out)))
  );
}

세 겹이에요. verb는 allowlist 3개로만, 인자는 정규식으로 128자 이내, 실행은 배열이라 셸 해석 자체가 없어요. quote 이스케이프는 마지막 보조수단이고, 첫 방어는 문법을 만들 기회를 주지 않는 거예요.

LLM 출력verb allowlist인자 정규식execFile 배열 실행차단 로그

테스트해 보면 드러나는 것

테스트는 1개만 남겼어요. runGit("status", ["; whoami"])가 throw 하는지 보는 거예요. 적용해 봤더니 정규식 단계에서 먼저 막혔고, 정규식을 지우고 돌려 보니 execFile이 ; whoami를 경로 인자로 넘겨서 git이 경로를 못 찾았다는 에러만 냈어요. 둘 중 하나가 빠져도 셸은 안 열리지만, 둘 다 있어야 로그에 왜 막혔는지가 남아요.

옵션도 두 개 챙겼어요. timeout을 10초로 두면 멈춘 프로세스가 SIGTERM으로 정리되고, maxBuffer 기본값은 1MiB라서 출력이 넘치면 자식 프로세스가 종료돼요. 무한 대기와 메모리 폭주를 두 줄로 막는 셈이에요.

백엔드에서 이 코드의 자리

이건 입력 검증의 한 종류예요. API 파라미터를 zod로 검사하듯, 에이전트 출력도 신뢰 경계 밖에서 온 값으로 다뤄요. 인가 판단과 같은 원칙이기도 해요. LLM이 "이 유저는 관리자"라고 말해도 서버가 세션에서 다시 확인하듯, LLM이 준 명령도 서버가 정한 verb 목록 안에서만 실행돼요.

라이프케어로그에서 플랜비 운영 배치를 자동화할 때 이 래퍼를 먼저 깔았어요. 에이전트가 무엇을 만들어 내든, 서버가 열어 둔 문은 3개뿐이니까요.

#node.js#명령 인젝션#AI 에이전트 보안#child_process

라이프케어로그 서비스가 궁금하신가요?

AI 기반 건강·일정·재활 관리 앱을 직접 써보세요.

서비스 살펴보기

관련 글

댓글

아직 댓글이 없어요. 첫 댓글을 남겨주세요.