01 소개 02 경력 03 작업 04 블로그 05 이력서 PDF
← 블로그 목록
// 의존성

의존성 취약점 분석 자동화 — 보고서 생성부터 LLM 영향도 분석·플래닝까지


pnpm audit으로 끝나던 취약점 점검을, 보고서 생성 → 영향도 분석 → 업데이트 플래닝까지 이어지는 자동화 파이프라인으로 만든 기록. LLM은 영향도 분석·플래닝을 참고용으로 거드는 보조 도구로 붙였음.

배경

대규모 Vue 커머스 프론트오피스에서 pnpm audit을 돌리면 이렇게 나옴.

총 113개 · 🔴 Critical 2 · 🟠 High 40 · 🟡 Moderate 59 · 🟢 Low 12

문제는 이 숫자 이후가 전부 수작업이라는 점이었음. 113개를 다 올릴 수 없으니 매번 사람이 advisory를 하나씩 읽으며 우선순위를 정하고, 영향 범위를 가늠하고, 플랜을 짰음. axios 하나에만 취약점이 25개씩 붙는 상황에선 이게 반복 노동이었음.

목표는 명확했음. 이 반복을 파이프라인으로 자동화하는 것. 정기적으로 돌려서 항상 최신 상태의 보고서와 플랜 초안이 나오게 만드는 것.

파이프라인 구조

네 단계로 자동화했음.

pnpm audit --json
  → ① 취약점 보고서(md)        [자동, 결정적]
  → ② 코드베이스 근거 수집       [자동, grep]
  → ③ LLM 영향도 분석·플래닝     [참고용]
  → docs/audit/*-YYYY-MM-DD.md

핵심은 ①②가 자동·결정적이고, ③은 그 위에 얹는 참고용 레이어라는 점. LLM 없이 node scripts/audit-report.js만 돌려도 ①②까지는 항상 나옴.

① 취약점 보고서 자동 생성

pnpm audit --json을 파싱해 심각도순으로 정렬하고 직접/간접 의존성을 나눠 표로 그림. 분류 기준은 의존성 경로 깊이.

// findings.paths 의 한 경로가 ".>패키지명" (depth 2) 이면 직접 의존성
const isDirect = a.findings.some(f => f.paths.some(p => p.split('>').length === 2))

직접 의존성은 package.json을 직접 고쳐야 하고, 간접은 overrides나 업스트림을 기다려야 하니 대응이 다름. 그래서 섹션을 나눔. 결과물은 docs/audit/audit-report-2026-07-10.md 같은 날짜별 파일로 떨어짐. 여기까진 순수 자동화, LLM 관여 없음.

② 코드베이스 근거(evidence) 자동 수집

③의 영향도 분석을 참고용이라도 쓸모 있게 만들려면 근거가 필요했음. LLM은 우리 코드베이스를 모르기 때문. advisory만 보고 “영향도 높음” 하면 그건 일반론이자 추측임.

그래서 각 취약 패키지를 코드에서 직접 grep해 사실을 수집함.

{
  "package": "qs",
  "importFileCount": 9,          // import 하는 파일 수 (blast radius)
  "context": "runtime",          // dependency vs devDependency
  "riskySymbol": {
    "matched": true,             // 취약 API를 실제로 호출하는가
    "fileCount": 2,
    "sample": ["src/system/utils/legacy.ts"]
  }
}

핵심은 riskySymbol.matched. “import 했다”와 “취약한 그 함수를 실제 호출한다”는 완전히 다름.

  • false → import은 했지만 취약 함수는 안 씀 → 영향 낮음
  • true → 실제 호출 → 영향 있음
  • null → 정적 판별 불가 → 수동 확인

실제로 돌려보면 이렇게 나옴.

패키지import취약 심볼근거
lodash-es386template( 미검출386개 import는 개별 함수, 취약점(_.template)은 미사용
axios10proxy: 미검출프록시 계열 취약점 경로 미사용
qs9qs.parse 검출(2)실제 사용, 영향 있음

386개나 import하는 lodash-es도 취약 함수는 안 쓴다는 게 데이터로 나옴. 이 수치가 ③의 입력이 됨.

grep은 두 단계로 하되 패키지 import 파일 내부에서만 심볼을 찾아 오탐을 줄임.

const importFiles = grepFiles(`['"\`]${pkg}(/|['"\`])`)       // 1) import 파일
const hit = grepInFiles('\\.parse[[:space:]]*[(]', importFiles) // 2) 그 안에서만

macOS 함정 하나. BSD grep은 \b, \s, \(를 GNU와 다르게 처리함(에러/오작동). 그래서 패턴은 POSIX ERE로만 씀 — 공백 [[:space:]], 여는 괄호 [(]. 로컬(맥)과 CI(리눅스)가 같은 결과를 내게 하는 이식성 조치.

③ LLM 영향도 분석·플래닝 (참고용)

②의 근거와 보고서를 프롬프트에 넣고 LLM(Gemini/Claude)에게 플랜을 시킴. 출력은 미조치 시 노출 위험, 영향도(높음/중간/낮음), 우선순위 업데이트 플랜, 회귀 테스트 범위.

여기서 원칙을 분명히 했음. LLM 결과는 참고용임. 이유는 셋.

  • 지식 기반 분석임 — CHANGELOG를 라이브 스크래핑하지 않음. 100여 개 패키지를 GitHub에서 긁는 건 rate-limit·불안정으로 오히려 리스크였음. LLM의 학습 지식 + ②의 근거로 판단함.
  • 영향도 판정은 evidence 인용이 조건임 — 프롬프트에서 “악용 가능성은 반드시 evidence 수치를 근거로 판정하라”고 강제함. matched:false면 “미사용 → 낮음”처럼. 그래도 추정이 섞임.
  • 최종 결정은 사람 — 플랜은 초안임. 자동화의 목적은 사람 판단을 없애는 게 아니라 출발점을 데이터로 깔아주는 것이었음.

즉 자동화의 신뢰 구간은 ①②(결정적)이고, ③은 그 위의 참고 레이어로 위치를 명확히 못박음.

자동화를 지탱하는 것들

정기적으로 돌리는 자동화라, “매번 실행”을 견디는 설계가 필요했음.

취약 심볼 매핑의 하이브리드 캐시

②의 심볼 패턴을 코드에 하드코딩하면 안 됐음. advisory는 계속 추가되고(axios만 25개), 취약 함수도 제각각이라 audit 결과가 바뀔 때마다 스크립트를 고쳐야 했기 때문.

그렇다고 심볼을 매번 LLM으로 생성하면 자기참조에 빠짐. evidence의 목적이 “LLM 추측 말고 정적 사실로 판정”인데, 그 사실을 LLM이 만들면 근거의 의미가 사라짐. 매 실행마다 패턴이 달라져 비결정적이기도 함.

그래서 심볼 매핑을 audit-risky-symbols.json 캐시로 분리하고 두 종류로 나눔.

  • byAdvisory — GHSA advisory id 키, LLM 생성분(source:"llm"). advisory 단위라 같은 패키지도 취약점마다 다른 심볼 가능.
  • byPackage — 사람이 검증·교정한 항목(source:"manual"). LLM 오탐을 여기서 고정.

갱신은 최소화함. 매번 LLM을 부르지 않음.

function needsGen(a, cache) {
  if (cache.byPackage[a.module_name]) return false       // 사람 고정분은 안 건드림
  const e = cache.byAdvisory[advId(a)]
  if (!e) return true                                     // 신규 advisory만 생성
  if (e.source === 'manual') return false
  if (e.promptVersion !== SYMBOL_PROMPT_VERSION) return true
  if (e.advisoryUpdated !== a.updated) return true        // advisory 갱신 시
  return false
}

이러면 audit을 매일 돌려도 LLM은 처음 보는 GHSA에만 붙음. 이미 본 취약점은 캐시에서 나오니 결정적이고 비용도 신규분에만 듦. 자동화의 이점(신규 자동 커버)은 챙기고, 순수 매번생성의 비결정성은 피한 타협점이었음.

반복 실행 대비 최적화

당일 재생성 스킵. 같은 날 이미 만든 파일이 있으면 덮어쓰지 않음. 보고서·플랜이 다 있으면 pnpm audit 실행조차 건너뜀. 정기 실행이 중복돼도 안전함.

심볼 grep 메모이즈. axios proxy: 패턴은 6개 advisory가 공유함. 순진하게 짜면 같은 grep을 6번 돔. pkg|pattern을 키로 캐시함.

const symMemo = {}
const grepSym = (pkg, pattern, files) =>
  (symMemo[`${pkg}|${pattern}`] ??= grepInFiles(pattern, files))

정기 실행 (macOS 삽질기)

로컬 맥에 cron을 붙이려다 헤맴.

  • 파일이 안 생김/var/log는 root 소유라 사용자 cron이 못 씀. macOS는 cron 데몬에 Full Disk Access(TCC) 권한도 요구함.
  • pnpm: command not found — nvm이 원인. zsh -lc-l(login)은 .zprofile만 읽는데 nvm PATH는 .zshrc(interactive)에 있음. 게다가 스크립트의 execSync('pnpm audit')는 Node가 /bin/sh -c로 실행 → Node PATH를 상속하니 거기 pnpm이 없으면 실패함.

결국 launchd LaunchAgent로 전환하고, 명령에서 nvm을 직접 source하거나 node bin 절대경로를 PATH에 prepend함. launchd는 요일 스케줄을 네이티브 지원하고 sleep 중 놓친 스케줄도 보충해줘 맥에 잘 맞았음. “첫째주 수요일”처럼 직접 표현 못 하는 스케줄은 매주 수요일 + date +%d ≤ 7 가드로 풀었음.

정리

  • 자동화의 신뢰 구간은 ① 보고서 · ② grep 근거 — 결정적이고 재현 가능함.
  • ③ LLM 영향도 분석·플래닝은 참고용 — 지식 기반 + 근거 인용이지만 추정이 섞이므로 최종 판단은 사람.
  • 심볼 grep은 휴리스틱임. qs.parseJSON.parse를 오탐할 수 있음. 완전한 데이터플로 분석이 아니라, matched:null·오탐은 명시적으로 수동 확인 대상으로 남김.
  • 가장 중요한 원칙: 판단은 LLM에게 맡겨도, 판단의 근거까지 LLM이 만들게 하지 않음. 근거는 결정적·검증 가능해야 함. grep이 그 역할을, 하이브리드 캐시가 자동화와 신뢰성의 타협점을 맡음.

스택: Node 22+ (native fetch, --env-file), pnpm audit --json, Gemini/Claude API (참고용). 산출물은 docs/audit/에 날짜별 Markdown.


← 목록으로
이 글이 도움이 됐다면 메일로 알려주기 →