ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 한 줄짜리 보안 패치가 아니었다: CI 신호를 믿지 못한 네 장면과 조용한 임베딩 폴백

    MEMENTO 2026. 11. 5. 09:06
    반응형

    어느 날 오픈소스 프로젝트 Memento의 Security Check 워크플로가 빨개졌다. 이미 면제 목록에 올려 둔 패키지 4개가 이유였고, 해당 코드를 건드린 사람은 없었다. 테스트 워크플로의 잡 9개는 모두 초록이었다. 원인을 찾아 고친 변경의 package.json diff는 정말 한 줄이었다.

    -    "sharp": "^0.34.4",
    +    "sharp": "^0.35.4",
    

    PR 제목에 적기 민망할 만큼 작은 수정이다. 그런데 이 한 줄을 안전하게 머지하는 과정에서 CI가 보여 주는 빨강과 초록을 믿지 못할 장면이 네 번 나왔다. 나중에는 이 한 줄이 수정의 본체라는 설명까지 틀렸다고 밝혀졌다. 이 글은 공개 저장소에 남은 이슈와 PR, 코드를 따라 그 네 장면을 되짚는다. 작업은 플래너(Opus), 워커(Cursor), 리뷰어(Sonnet)로 나눈 에이전트 구성이 맡았고, 아래 측정과 뮤테이션 대부분은 그 과정의 기록이다.

    요약

    • 면제 목록은 영구 면제 장부가 아니었다. 상류에 수정본이 나오는 순간 코드 변경 없이 면제가 풀리고 게이트가 빨개졌다.
    • 업그레이드 대상은 MiniLM 임베딩 경로였다. 이 경로는 깨져도 오류 없이 TF-IDF로 내려가고, 기본 테스트는 실제 패키지를 로드하지 않아 초록을 유지한다.
    • 일부러 코드를 망가뜨려 보니 테스트 11개가 그대로 통과했고, 분기를 통째로 지워도 초록인 테스트가 나왔다.
    • 후속 작업에서 Docker 빌드의 실패 모드가 예상과 정반대로 드러났다. 빌드는 성공하고 docker run에서 터진다.
    • 이슈에 적힌 전제는 측정 앞에서 거듭 뒤집혔다. 이슈 본문은 작성 당시의 추측이다.

    1. 아무도 안 건드렸는데 빨개진 게이트

    이슈 #989의 첫 진단은 평범했다. npm audit --omit=dev가 high 4건(adm-zip, onnxruntime-node, @huggingface/transformers, sharp)을 보고한다. 전부 @huggingface/transformers 의존 체인이니 올리면 된다. 측정을 시작하자 이 서술에서 틀린 곳이 두 군데 나왔다.

    우선 게이트는 평범한 npm audit이 아니다. 워크플로는 scripts/check-production-audit-fixable.mjs를 돌리고, 이 스크립트는 고칠 방법이 있는 취약점만 실패시킨다. 상류가 아직 고치지 못한 취약점까지 빌드를 막으면 저장소가 손쓸 길 없이 CI만 멈추기 때문이다. 다음으로 이슈는 production 레인만 언급했는데, dev 의존까지 보는 --include-dev 레인도 함께 실패 중이었다.

    더 이상한 점은 따로 있었다. 문제의 패키지 4개는 면제 목록 security/accepted-audit.json에 이미 전부 올라 있었다. 그런데도 게이트가 실패했다. 답은 분류 순서에 있다.

    // scripts/check-production-audit-fixable.mjs
    // prod 레인은 기존 동작(fixAvailable truthy)을 그대로 둔다. dev 포함 레인만
    // major-only 수정을 "고칠 수 없음"으로 분류한다.
    const isBlocking = includeDev
      ? (v) =>
          v.fixAvailable === true ||
          (typeof v.fixAvailable === 'object' &&
            v.fixAvailable !== null &&
            v.fixAvailable.isSemVerMajor !== true)
      : (v) => Boolean(v.fixAvailable);
    

    스크립트는 취약점을 먼저 fixable과 accepted로 나누고, 면제 목록은 accepted 쪽에서만 조회한다. fixable로 분류된 항목은 목록을 들여다보지도 않는다. 상류에 수정본이 publish되면 fixAvailable이 false에서 truthy로 바뀌고, 면제는 그 순간 저절로 풀린다.

    그러니 이 파일의 정체는 "이 패키지를 영구히 면제한다"는 선언이 아니다. "지금 상류에 고칠 방법이 없다"는 사실의 스냅샷이다. 저장소 밖의 사건 하나로 만료되는 면제이고, 코드가 그대로인데 CI가 빨개진 이유도 여기에 있다. 이 구조를 이해하면 흔한 대안인 "업그레이드 대신 면제 목록을 늘리자"는 선택지가 사라진다. 이미 등재된 항목조차 조회하지 않으므로 스크립트를 고치지 않는 한 소용이 없다.

    처음 보면 불편한 동작이지만 설계로서는 꽤 좋다. 면제가 조용히 영원해지는 일을 막는다. 대가는 예고 없는 빨간불이다. 이 게이트가 빨개지면 "최근 누가 뭘 머지했나"보다 "상류에 뭐가 나왔나"를 먼저 물어야 한다.

    같은 패키지가 레인마다 반대로 분류되는 점도 로그를 읽을 때 함정이 된다. sharp의 fixAvailable은 {version: '0.35.4', isSemVerMajor: true} 형태의 객체였다.

    레인 판정식의 결과 분류 결말
    prod Boolean(객체) = true fixable 면제 목록 조회 없이 실패
    dev isSemVerMajor = true accepted 면제 목록 조회 후 통과

    2. 임베딩이 죽어도 테스트는 초록이다

    의존성 패치라면 빌드와 테스트 통과를 확인하고 끝내고 싶어진다. 이번 변경에서 진짜 위험은 그 지점에 숨었다. @huggingface/transformers는 Memento의 MiniLM 임베딩 경로다. PR 기록에 따르면 과거 이 패키지가 깨졌을 때 EMBEDDING_PROVIDER=minilm 설정이 오류 없이 TF-IDF로 내려앉은 이력이 있다. 코드를 따라가면 왜 아무도 눈치채지 못하는지 세 겹의 이유가 나온다.

    첫째 겹은 테스트 설정이다. vitest.setup.ts가 @huggingface/transformers와 onnxruntime-node를 전역 mock한다. 기본 스위트는 실제 패키지를 한 줄도 로드하지 않으므로, 이 패키지가 어떻게 깨져도 결과가 같다.

    둘째 겹은 가용성 검사다. lazy loading을 택한 대가로 검사가 언제나 참을 돌려준다. provider를 고르는 단계에서 고장을 감지할 길이 원리적으로 없다.

    // minilm-embedding-service.ts
    isAvailable(): boolean {
      // MiniLM은 항상 사용 가능 (모델이 로드되지 않아도 사용 가능)
      // 실제 모델 로딩은 generateEmbedding 호출 시 lazy loading
      return true;
    }
    

    셋째 겹은 폴백이다. UnifiedEmbeddingService는 예외를 잡아 minilm, openai, gemini, tfidf 순서의 다음 provider로 넘어간다. 유일한 경고마저 if (!isCliQuiet()) 뒤에 있어서, 조용한 모드에서는 stderr에도 아무것도 남지 않는다.

    🔍 클릭하여 확대
    flowchart TD
        Dep["가정: 업그레이드로 MiniLM 고장"] --> T["기본 테스트"]
        T -->|"전역 mock이라 실제 패키지 미로드"| Green["CI 초록"]
        Dep --> Avail["isAvailable()"]
        Avail -->|"항상 true"| Gen["generateEmbedding 호출"]
        Gen -->|"throw를 catch"| Fall["다음 provider로 폴백, tfidf 안착"]
        Fall --> Store["512차원 벡터 저장, 응답은 정상"]
        Store --> Filter["검색은 MiniLM 모델명으로 필터"]
        Filter --> Lost["폴백 행은 검색에서 제외, 에러 없음"]
      

    2차 피해가 더 나쁘다. MiniLM은 384차원, TF-IDF는 512차원이다. 폴백이 일어나면 두 차원의 벡터가 저장소에 섞인다. 벡터 검색은 provider가 minilm일 때 getEmbeddingModelFilter로 MiniLM 모델명만 고르므로, 폴백 중에 저장된 행은 검색 대상에서 빠진다. 저장은 성공하고 검색도 성공한다. 결과만 줄어든다. 이런 장애는 사용자가 "요즘 기억을 잘 못 찾는 듯하다"고 말하기 전까지 아무 경보도 울리지 않는다.

    폴백 자체가 나쁜 설계는 아니다. 기억 저장 서버가 임베딩 하나 때문에 쓰기를 거부하면 더 곤란하다. 문제는 복원력을 위해 넣은 장치가 실패 신호까지 함께 삼켰다는 데 있다.

    이런 고장을 잡으려면 "임베딩이 나왔다"가 아니라 "MiniLM이 만든 임베딩이 나왔다"를 확인해야 한다. 그래서 전용 스펙에 mock을 풀고 실제 모델을 태우는 단언을 넣었다. 관측할 지렛대는 둘이다. TF-IDF와 겹치지 않는 차원(384)과, 결과에 기록되는 provider 필드다. 모델 파일이 약 118MB라 기본 스위트에는 넣지 않고 RUN_EMBEDDING_QUALITY=1 환경 변수로 분리했다. 초록인 줄 알았는데 건너뛴 결과인 상황을 막으려고 REQUIRE_EMBEDDING_QUALITY=1일 때 꺼져 있으면 실패하게도 했다.

    처음 만든 단언 일곱 개(V1~V7)는 mock 해제, 차원, 모델명, 벡터 품질, 검색 순위를 꼼꼼히 봤다. 빠진 곳을 리뷰가 짚었다. 일곱 개 모두 MiniLMEmbeddingService를 직접 new한다. 폴백은 그 바깥의 UnifiedEmbeddingService에서 일어나므로, 이 테스트들은 정작 폴백 지점을 지나지 않는다. 리뷰어는 이 상태로 위험이 닫혔다고 보고하면 과장이라고 지적했고, 여덟 번째 단언이 추가됐다.

    it('UnifiedEmbeddingService는 minilm provider로 임베딩한다 — tfidf 폴백이 아님 (V8)', async () => {
      // ...
      const unified = new UnifiedEmbeddingService();
      const result = await unified.generateEmbedding(ANCHOR_DOC);
      expect(result).not.toBeNull();
      expect(result!.provider).toBe('minilm');
      expect(result!.embedding).toHaveLength(384);
      // ...
    });
    

    테스트를 어디에 붙이느냐가 테스트의 내용만큼 중요하다는 사례다. 위험이 일어나는 경로를 지나지 않는 테스트는 아무리 촘촘해도 그 위험을 보지 못한다.

    한 가지 짚어 둔다. isAvailable()의 return true와 폴백 구조는 지금도 main에 그대로다. 이 스펙은 PR마다 돌지 않고, 이름과 달리 매주 일요일 실행되는 Nightly Tests 워크플로에서 돈다. 이 스펙으로 잡는 시점은 머지 때가 아니라 다음 주간 실행이다. 구조적 해결이 아니라 탐지 수단을 놓은 셈이다.

    3. 분기를 지워도 통과하는 테스트

    세 번째 장면은 PR에 새로 들어간 테스트에서 나왔다. 리뷰어는 구현을 일부러 망가뜨려 테스트가 깨지는지 봤다. 이른바 뮤테이션 검증이다.

    면제 목록 로더 loadAcceptedAuditAllowlist를 "파일 내용을 무시하고 항상 빈 Set을 돌려준다"로 망가뜨렸더니 테스트 11개가 전부 통과했다. 로드에 실패하면 닫히는지(fail-closed)는 검증했지만, 정상 파일을 제대로 읽는지 보는 테스트가 없었다. 이 PR로 면제 목록이 비었으니 당장은 드러날 일도 없었다. 파일이 다시 채워지는 날, 곧 이 파일이 존재하는 이유가 실현되는 날에 회귀를 아무도 잡지 못한다.

    더 교묘한 사례도 나왔다. --include-dev에서 major-only high가 목록에 없으면 실패한다는 테스트의 픽스처에 레거시 패키지 4개가 함께 들어 있었다. 이 4개가 독립적으로 같은 실패 메시지를 만들어 내는 바람에, 검증 대상인 isSemVerMajor 분기를 통째로 지워도 테스트가 통과했다. 눈으로 읽으면 멀쩡하고 실행하면 초록이다. 지우는 편이 나아서 삭제했다.

    최종 머지 전 오케스트레이터는 워커의 보고만 믿지 않고 되돌리기 여섯 건을 직접 재현했다.

    되돌린 것 결과
    package.json을 ^0.34.4로 dependency-range.spec.ts 실패
    accepted-audit.json에 sharp 되살림 3건 실패 (문서 동기화 포함)
    vi.unmock 두 줄 제거 MiniLM 스펙 실패
    loadAcceptedAuditAllowlist 파싱 무시 새 happy-path 테스트 실패
    dev 레인 isSemVerMajor 예외 제거 major-only 테스트 실패
    result.provider = 'tfidf' 강제 V8 실패

    마지막 행이 2장의 폴백 시나리오를 직접 흉내 낸다. V8이 추가되기 전이었다면 이 변이는 살아남았을 자리다. 초록은 "테스트가 통과했다"는 뜻이고, 테스트가 무언가를 확인했다는 증거는 망가뜨렸을 때 빨개지는 모습뿐이다.

    4. 빌드는 성공하고 컨테이너에서 터진다

    #989는 Docker 이미지 빌드를 검증하지 못한 채 머지됐고, 후속 #993이 그 숙제를 받았다. 이슈에는 두 가지가 적혀 있었다. sharp 직접 의존은 transformers의 네이티브 바이너리를 top-level로 hoist하려는 장치로 보인다는 추정, prebuilt가 안 잡히면 --build-from-source로 내려가 libvips 소스 빌드가 오래 걸리거나 실패한다는 우려였다. 둘 다 측정으로 무너졌다.

    $ npm view sharp@0.34.5 scripts.install
    node install/check.js || npm run build
    $ npm view sharp@0.35.4 scripts.install
    (빈 출력)
    

    0.35에서 install script가 사라졌다. 설치 시점에 소스 빌드로 내려갈 경로 자체가 없다. Dockerfile의 다음 줄은 앞항이 항상 no-op으로 성공하니 뒤쪽 ||가 발동할 일이 없는 죽은 코드가 됐고, 이 작업에서 삭제됐다.

    (npm rebuild sharp 2>/dev/null || npm rebuild sharp --build-from-source)
    

    실제 실패 모드는 정반대다. prebuilt 바이너리가 깔리지 않아도 빌드는 조용히 성공하고, 터지는 곳은 런타임이다.

    Error: Could not load the "sharp" module using the linux-x64 runtime
    

    이슈의 리스크 모델대로 움직였다면 빌드 시간만 재고 넘어갔다. 리스크 모델이 틀리면 검증 절차도 함께 틀린다. 실제 검증은 일회성 컨테이너 실행까지 갔다. 컨테이너 안에서 require('sharp')와 네이티브 동작, @img/* 설치 상태, MiniLM warmup을 확인했다. --no-cache는 쓰지 않았지만 npm ci 레이어가 CACHED가 아니라는 사실을 빌드 로그로 확인했다. 캐시를 재사용한 빌드라면 검증이 무효이기 때문이다.

    이 측정은 처음 이야기의 "한 줄"도 다시 썼다. 보안 수정을 실제로 가져온 쪽은 root의 그 한 줄이 아니라 @huggingface/transformers 4.2.0에서 4.3.0으로 올라간 상류 변경이었다. transformers가 요구하는 sharp 범위가 ^0.34.5에서 ^0.35.4로 밀려 올라갔고, root 한 줄은 그 뒤에 장부를 맞춘 사후 정리였다.

    그 한 줄을 안 올리면 왜 안 됐을까? root 범위가 ^0.34.4에 머물면 npm이 sharp를 두 벌 설치한다. top-level에 취약한 0.34.x, transformers 아래에 0.35.4가 놓이고 게이트는 계속 실패한다. 이 함정을 막아 주는 듯 보이던 직접 의존이 사실은 함정의 원인이었다.

    시나리오 root 범위 transformers 범위 설치 결과 게이트
    #989 이전 ^0.34.4 ^0.34.5 0.34.5 하나 실패
    #989 머지 ^0.35.4 ^0.35.4 0.35.4 하나 통과
    #993 머지 (root 제거) 없음 ^0.35.4 0.35.4 하나 통과
    상류가 ^0.36으로, root 유지 ^0.35.4 ^0.36.x top과 중첩 두 벌 실패
    상류가 ^0.36으로, root 없음 없음 ^0.36.x 0.36.x 하나 통과

    요구자가 하나면 범위 불일치는 구조적으로 일어나지 않는다. sharp를 처음 넣은 커밋 메시지는 이미지 처리용이라고 적었지만, 프로덕션 코드에 import sharp는 없었다(grep 0건). 의도된 hoist 장치가 아니라 남겨진 흔적이었다. 제거의 위험은 npm ci --omit=dev --ignore-scripts --dry-run으로 쟀다. 변경 전후 모두 309 packages를 설치한다. 코드를 지우기 전에 배포 산출물이 같다는 증거부터 만든 셈이다.

    수동 Docker 검증은 한 번뿐이니 재발을 막지 못한다. 머지는 초록인데 배포가 깨지는 경로를 막으려고 #996이 열렸고, PR #1011이 이미지 빌드와 네이티브 스모크 잡을 CI에 넣었다. 여기서 전제가 또 한 번 뒤집혔다. 이슈는 과거 측정치 14분 21초를 들어 비용을 걱정했는데, 러너의 콜드 빌드 실측은 83초, 잡 전체는 1분 30초였다. test-core(2분 22초)보다 짧다. 로컬 빌드 시간으로 CI 비용을 추정하면 안 된다는 또 하나의 측정이다.

    5. 이슈 본문은 추측이다

    네 장면을 나란히 놓으면 공통점이 보인다. 빨강도 초록도 신호 자체는 거짓이 아니었다. 각 신호가 실제로 보는 대상이 머릿속 가정보다 좁았을 뿐이다.

    신호 흔히 하는 해석 실제 의미
    게이트 빨강 최근 코드 변경이 문제를 만들었다 상류에 수정본이 나와 면제가 만료됐다
    테스트 초록 임베딩이 정상이다 mock 위의 코드가 정상이다
    새 테스트 초록 새 분기를 검증했다 픽스처가 어떻게든 같은 메시지를 만들었다
    docker build 성공 이미지가 동작한다 레이어가 만들어졌다

    같은 구조가 사람의 문서에서도 반복됐다. #989, #993, #996 세 이슈 모두에서 본문의 전제가 측정으로 뒤집혔다. 게이트의 정체, 직접 의존의 목적, 빌드 실패 모드, CI 비용이 모두 그랬다. 이슈를 부실하게 쓴 탓으로 돌리기는 어렵다. 이슈는 대개 그 작업을 아직 하지 않은 시점에 쓰이니 원래 측정 없는 추정이다. #989를 마치며 "root를 올려야 이중 설치를 막는다"고 정리한 설명도 바로 다음 작업의 측정 앞에서 "root가 원인"으로 고쳐졌다. 완료 기록까지 틀리는 마당이니, 후속 작업의 첫 단계는 구현이 아니라 전제 검증이어야 한다. 검증 비용이 대개 명령 한두 줄이라는 점을 생각하면 더 그렇다.

    핵심 정리

    • 면제 목록에 등록된 항목은 고칠 방법이 생기면 만료되게 만든다. 대신 게이트가 빨개지면 상류 릴리스부터 확인한다.
    • 폴백은 실패 신호를 삼킨다. 폴백이 있는 경로에는 "결과가 나왔다"가 아니라 "기대한 경로로 나왔다"를 단언한다.
    • 위험이 일어나는 호출 경로를 지나는지 먼저 본다. 하위 구현을 직접 new한 테스트는 상위의 폴백을 보지 못한다.
    • 전역 mock 아래의 초록은 실제 패키지에 대해 아무 말도 하지 않는다. 비싼 실연동 테스트는 환경 변수로 분리하되 건너뜀을 실패로 바꾸는 장치를 함께 둔다.
    • 새 테스트는 구현을 망가뜨려 빨개지는지 확인한다. 픽스처가 다른 경로로 같은 메시지를 만들면 초록은 무의미하다.
    • docker build 성공은 런타임 로드를 보장하지 않는다. 네이티브 의존은 컨테이너 안에서 실제로 불러 본다.
    • 이슈에 적힌 원인과 위험은 가설로 읽는다.

    참고 자료

    반응형
Designed by Tistory.