주요 내용
- Git URL 및 원격 타르볼 종속성은 해당 위치에서 직접 가져오며, 절대 귀하의 레지스트리를 통해 가져오지 않습니다.
- 당사가 추적하는 53,707개의 패키지 버전 중 811개의 종속성 사양이 레지스트리 외부에서 해결됩니다.
- 브랜치, 태그 및 고정 해제된 참조가 진정한 위험 요소입니다. 릴리스 없이도 해당 코드가 변경될 수 있기 때문입니다.
- 바이트 단위로 재구성하는 것은 포함 여부를 확인하는 것이 아니라 정확성을 입증하는 것이므로, 정확하게 재현된 이스케이프도 여전히 이스케이프입니다.
비공개 방식으로 운영하는 대부분의 팀은 npm 레지스트리도 이를 똑같이 설명합니다. 모든 것은 Nexus나 Artifactory를 통해 처리되며, 공개 레지스트리는 프록시 뒤에 위치해 있고, 개발자의 노트북이나 CI 실행 서버에는 그들이 통제하는 그 하나의 관문을 거치지 않고는 어떤 것도 들어가지 않습니다.
그 말은 대체로 사실입니다. 여기서 ‘대체로’라는 부분이 바로 논할 가치가 있는 부분입니다.
소스 코드에서 오픈소스 패키지를 재컴파일하고 출처 정보를 함께 제공하는 TuxCare의 서비스인 SecureChain을 개발하는 과정에서, 우리는 이 원칙이 적용되지 않는 사례를 찾아보았습니다. 우리는 총 53,707개의 버전을 검토하여 npm 의존성 그래프 내의 패키지 중에서, 우리는 다음을 나타내는 모든 의존성을 추적하고 표시했습니다. npm 레지스트리를 아예 건너뛰기 위해서입니다. 이 글에서는 우리가 발견한 내용, 레지스트리 구성으로는 이러한 종속성을 리디렉션할 수 없는 이유, 그리고 이번 주에 여러분의 프로젝트에서 무엇을 확인해야 하는지 다룹니다.
‘탈출 의존성’이란 무엇을 의미하는가
'미러 회피 의존성'이란 패키지 이름과 버전 범위 대신 Git URL, 타르볼 URL 또는 기타 원격 위치로 선언된 의존성을 말하므로, npm 레지스트리를 통해 경로를 확인하는 대신 해당 위치에서 직접 가져옵니다.
여기 하나 있습니다. 이름은 변경되었지만, 형태는 우리가 공개된 패키지의 런타임 종속성에서 발견한 것과 정확히 일치합니다:
"dependencies": {
"lodash": "^4.15.0",
"some-markdown-lib": "git://github.com/some-user/some-markdown-lib.git"
}
첫 번째 줄은 일반적인 경우입니다. npm 사용자가 설정한 레지스트리에 대해 다음을 요청합니다. lodash 다음 조건을 만족하는 버전 ^4.15.0. 두 번째 줄에는 버전, 커밋, 레지스트리가 없습니다. 이 줄은 개인 GitHub 계정의 저장소 이름을 지정하고 있으며, npm 설치하는 시점에 기본 브랜치에 있는 내용을 가져옵니다.
만약 그렇다면 git:// prefix가 더 이상 작동하지 않는 것처럼 보인다면, 눈썰미가 좋으시군요. GitHub는 2022년에 인증되지 않은 git 프로토콜을 중단했습니다. 그렇다고 해서 이 의존성이 무용지물이 되는 것은 아닙니다. npm GitHub를 표준화합니다 git:// URL을 HTTPS로 변경해 두었기 때문에, git 의존성을 허용하는 어떤 환경에서도 정상적으로 해결되고 설치됩니다. 코드 검토에서는 문제가 있는 것처럼 보였던 사양이 실제로는 잘 작동하는데, 이런 식으로 수년 동안 아무도 눈치채지 못한 채 지나갈 수 있습니다.
레지스트리 설정으로는 왜 이를 변경할 수 없는가
그리고 registry 정착하다 .npmrc 한 가지 질문에 답합니다. 종속성이 이름과 범위로 이루어져 있을 때, 어디에 npm 찾아볼까요? Git URL이나 HTTPS 타르볼 링크는 그런 종류의 의존성이 아닙니다. 그 자체로 위치를 포함하고 있으며, npm 거기로 바로 이동합니다. 범위 지정 레지스트리 재정의, 인증 토큰, 앞에 위치한 프록시 리포지토리 npmjs.org: 그런 건 전혀 고려 대상이 아니기 때문에, npm 애초에 해당 종속성에 대한 레지스트리 요청을 전혀 하지 않습니다.
계층 구조의 깊이도 도움이 되지 않습니다. 트리에서 세 단계 아래에 있는 패키지가 이를 선언하면, 설치 과정에서 설치 프로그램이 GitHub에 접속을 시도하게 되는데, 이는 사용자는 해당 노트북이나 러너가 자체 레지스트리와만 통신한다고 가정했던 상황에서 발생합니다. 해당 기기가 GitHub에 접속할 수 없으면 설치가 실패하게 되며, 많은 팀이 이런 문제가 존재한다는 사실을 이 방식으로 알게 됩니다. 접속이 가능할 때는 아무런 오류도 발생하지 않아 아무도 눈치채지 못합니다.
락파일은 사람들이 기대하는 것만큼 큰 도움이 되지 않습니다. npm git 종속성에 대해 해결된 커밋을 기록하므로, 동일한 락파일을 사용하여 설치를 반복하면 동일한 코드를 얻게 됩니다. 하지만 첫 번째 해결 과정에서는 여전히 그날 해당 브랜치에 있던 내용을 가져오며, 모든 fetch 작업은 여전히 사용자의 레지스트리가 아닌 GitHub으로 이루어집니다. 락파일은 콘텐츠의 내용을 고정할 뿐, 콘텐츠의 출처는 변경하지 않습니다.
53,707개의 패키지 버전이 우리에게 알려준 것
총 53,707개의 패키지 버전을 분석한 결과, 레지스트리 외부에서 해결되는 811개의 종속성 사양을 발견했으며, 이는 303개의 서로 다른 패키지에 속한 682개 버전에 분포되어 있었습니다. 그중 730개는 GitHub에서 제공되는 것이었습니다. 나머지는 CDN 링크, 벤더 다운로드 호스트, 개인 호스팅 등이 뒤섞여 있었으며, 종속성으로 사용된 GitHub Gist도 하나 있었습니다.
811개의 사양 중 636개는 개발 의존성입니다. 공개된 패키지를 사용하는 사용자는 일반적으로 이러한 의존성을 설치하지 않지만, 패키지 자체가 빌드될 때마다 해당 의존성은 자동으로 가져오게 되며, 이는 재현 가능한 빌드를 중요하게 여기는 경우 고려해야 할 사항입니다.
나머지 175개는 런타임 또는 선택적 종속성, 즉 사용자 대상 범주에 속합니다. 이 수치에는 별표(*)를 달아야 하며, 나중에 누군가가 지적하기 전에 지금 미리 언급해 두는 편이 낫습니다. 이 175개 중 155개는 오랫동안 사용이 중단된 단일 CLI 도구에서 비롯된 것으로, 수십 개의 버전이 공개되었지만 우리가 추적하는 종속성 그래프에는 이를 의존하는 항목이 하나도 없습니다. 이 도구를 제외하면, 15개 패키지에 걸쳐 20개의 소비자 대상 이스케이프 사양만 남게 됩니다.
20개는 적은 수로 보이며, 실제로도 그렇습니다. 누구도 이를 유행병처럼 받아들여서는 안 됩니다. 하지만 이 20개 중 13개는 브랜치나 비어 있는 저장소를 가리키고 있는데, 이는 그 뒤에 있는 코드가 언제든 변경될 수 있음을 의미합니다. 또한, 바로 이런 적은 수 때문에 이런 문제가 은폐되기 쉽습니다. 아무도 특별하다는 사실을 몰랐던 15개의 패키지에 대해서는 감사를 실시하지 않기 때문입니다.
모든 탈출이 똑같이 위험한 것은 아니다
스펙의 모양은 그것이 어디를 가리키는가보다 훨씬 더 중요합니다.
전체 커밋 SHA에 고정된 종속성은 콘텐츠 주소 지정 방식입니다. 리포지토리 소유자는 브랜치나 태그를 이동하는 것처럼 해당 참조를 다른 커밋으로 옮길 수 없기 때문에, 설령 내일 리포지토리의 소유권이 바뀌더라도 고정 시점에 존재했던 내용을 그대로 얻을 수 있습니다. 남은 문제는 가용성 문제와, 여전히 레지스트리를 우회한다는 사실입니다. 이는 코드 삽입 문제라기보다는 격리 문제입니다.
리포지토리 URL, 브랜치, 태그 및 semver로 해결된 git 참조는 모두 변경 가능한 것으로 간주해야 합니다. 참조가 전혀 없는 경우 기본 브랜치를 의미합니다. A #branch ref는 브랜치를 따릅니다. A #semver: 범위는 설치 시점에 일치하는 태그나 참조를 기준으로 해결됩니다. 그리고 태그에 대해서는 따로 언급할 필요가 있는데, 사람들은 이를 마치 버전처럼 취급하기 때문입니다. 하지만 태그는 버전이 아닙니다. 태그는 포인터일 뿐이며, 저장소 소유자는 원할 때마다 이를 다른 커밋으로 이동시킬 수 있습니다. SHA가 아니라면, 변경될 수 있다고 가정하십시오.
원격 타르볼 URL도 일반적으로는 변경 가능한 버킷에 포함되어야 합니다. 단, 그 무결성이 별도로 보장되는 경우는 예외입니다. GitHub에서 생성된 소스 아카이브의 체크섬은 아무도 저장소를 수정하지 않았음에도 변경된 적이 있으므로, 고정된 것처럼 보이는 링크라 해도 동일한 바이트가 포함되어 있다고 보장할 수는 없습니다.
우리의 조사 결과, 그 분포는 일방적이었습니다. 해당 분류에 따르면, 811개의 사양 중 687개는 변경 가능했고, SHA에 고정된 것은 124개에 불과했습니다. 우리는 다음을 집계하지 않았습니다. file:, link:, workspace:, 또는 상대 경로 자체도 마찬가지입니다. 이러한 경로는 총 1,061개였으며, 네트워크에 전혀 접근하지 않아 어떤 위협도 피할 수 없기 때문에 이를 제외했습니다.
이제 가변적인 경우에서 무엇이 필요하지 않은지 살펴봅시다. 계정 탈취도, 타이포스쿼팅도, 도난당한 게시 토큰도 필요하지 않습니다. 해당 포크의 소유자는 화요일 오후에 자신의 기본 브랜치로 푸시하기만 하면, 그 후 어디서든 새로 설치된 시스템이 이를 반영합니다. 버전 업그레이드도, 게시 이벤트도 없으며, 스캐너가 확인할 만한 레지스트리의 새로운 내용도 전혀 없습니다. URL이 누군가가 갱신을 잊은 도메인을 가리키고 있다면, 소유자조차 필요하지 않습니다. 그 도메인을 다음에 등록하는 사람은 누구나 해당 도메인에 대한 유효한 인증서를 발급받을 수 있으며, 여러분의 설치본이 무엇을 다운로드할지 결정할 수 있습니다.
친환경 건축만으로는 해결되지 않는다
이 부분이 우리에게 가장 신경 쓰였던 부분입니다.
재빌드 파이프라인은 정확성을 검증합니다. 소스 코드에서 빌드한 후 tarball을 압축하고, 이 tarball이 업스트림의 tarball과 바이트 단위로 일치하는지 확인합니다. 일치한다면, 우리의 재빌드 과정이 업스트림에서 제공한 내용을 단 한 바이트도 변경하지 않았음을 알 수 있습니다. 이는 매우 중요한 보증이며, 우리는 누구에게나 이를 확고히 옹호할 것입니다.
또한 사람들이 흔히 생각하는 그런 보장도 아닙니다. ‘충실함’이란 업스트림을 충실히 재현했다는 뜻입니다. 만약 업스트림의 매니페스트에 개인 포크에서 무언가를 가져오라고 명시되어 있다면, 충실하게 재구성한 결과물도 정확히 똑같은 내용을 나타냅니다. 충실하게 재현된 결함도 여전히 결함입니다.
출처(Provenance)도 동일한 범위를 갖습니다. SLSA 인증은 아티팩트가 어떻게, 어디서, 어떤 소스를 사용하여 빌드되었는지를 알려줍니다. 하지만 빌드가 완료된 지 몇 주가 지난 후, 다른 컴퓨터에서 해당 아티팩트가 고객의 패키지 관리자에게 무엇을 가져오도록 요청할지는 알려주지 않습니다. 이것이 바로 SLSA가 출처에 대해 정의하는 범위이며, 이는 타당한 범위입니다. 출처는 무언가가 어떻게 만들어졌는지를 증명할 뿐, 그것이 만들어져야 했는지에 대해서는 결코 증명하지 않습니다.
대신 빌드 과정에서 네트워크를 모니터링할 수도 있으며, 일부 재빌드 작업에서는 실제로 그렇게 하고 있습니다. 이렇게 하면 매니페스트 스캔으로는 절대 포착할 수 없는 문제들, 예를 들어 postinstall 어딘가에서 바이너리를 다운로드하는 스크립트입니다. 하지만 이 스크립트는 단지 여러분의 빌드 과정만을 설명할 뿐입니다. 실제로 중요한 설치 과정은 나중에 다른 사람의 컴퓨터에서 이루어지며, 여러분의 빌드 프록시는 이를 전혀 볼 수 없습니다. 각 접근 방식은 상대방이 가진 사각지대를 보완해 줍니다.
이 모든 과정에서 우리가 얻은 교훈은, 정확성과 포함 여부는 서로 다른 문제이며 각각에 대해 별도의 검증이 필요하다는 점입니다. 포함 여부 검사를 검증 단계에 통합해 버리면, 오류가 발생했을 때 어떤 문제가 실패했는지 더 이상 알 수 없게 됩니다.
상황을 오히려 더 악화시킬 뻔했던 수정 조치
가장 확실한 해결책은 이를 그대로 복제하는 것입니다. git 종속성을 적절한 패키지로 레지스트리에 복사하고, 사양(spec)을 일반적인 버전 범위로 다시 작성한 뒤, 다음 단계로 넘어가면 됩니다.
한 가지 문제가 있습니다. 편집하는 순간 package.json, 귀하의 타르볼은 더 이상 업스트림과 일치하지 않으므로, 정확도 검사에서 당연히 거부됩니다. 결국 바이트 단위의 정확성, 실제 포함 관계, 그리고 누구도 코드를 수정하지 않고도 바로 대체하여 설치할 수 있는 패키지 중 원하는 세 가지 중에서 하나를 선택해야 합니다. 그중 두 가지만 얻을 수 있습니다.
복사본에 이름을 붙이는 것 자체가 또 다른 함정이 되었습니다. 한 사례에서는 git 종속성이 수년 전의 개인 포크를 가리키고 있었는데, 그 포크의 package.json 어디에도 공개된 적이 없는 대문자로 표기된 패키지 이름과 버전을 선언했습니다. npm 대문자가 포함된 새로운 패키지 이름은 허용되지 않기 때문에 그 이름은 제외되었습니다. 소문자로 된 이름은 이미 공개 레지스트리에서 다른 포크가 더 낮은 버전 번호로 등록해 놓은 상태였습니다. 우리 버전을 더 높은 버전 번호로 그 이름 아래에 게시하면, 비공개 및 공개 소스의 후보들을 비교하도록 구성된 리졸버가 우리 버전을 우선적으로 선택할 수 있습니다.
그건 의존성 혼동을 일으키는 전형적인 사례나 다름없습니다. 우리가 해결하려던 바로 그 유형의 문제를 스스로 만들어낸 셈이 되었을 테니까요.
이러한 경우의 깔끔한 해결책은, 어떤 공개 변수와도 충돌하지 않는 새로운 범위 내 이름을 지정하고, 이를 참조하도록 소비하는 매니페스트를 재작성하는 것입니다. 작업량이 더 많이 들며, 이로 인해 패키지가 다른 사람의 브랜치를 자동으로 따르지 않게 됩니다. 하지만 우리는 이제 그 마지막 부분이 오히려 장점이라고 생각하게 되었습니다. 낯선 사람의 기본 브랜치를 아무런 알림 없이 추적하던 것이야말로 취약점이었지, 서비스 자체가 문제가 아니었습니다.
자신의 프로젝트에서 확인해야 할 사항
먼저 그들을 찾아보세요. npm 다음과 같은 쿼리 명령어를 사용하여 이를 수행할 수 있습니다:
npm query ":type(git), :type(remote)"
이 기능은 설치된 종속성 트리를 탐색하여 git 저장소나 원격 URL에서 유래한 모든 항목을, 전이적 종속성을 포함하여 반환합니다. 결과가 발견되면, 어떤 결과가 직접적인 것이고 어떤 결과가 3단계 아래에서 유래한 것인지 파악하고 싶을 것입니다.
이미 ~로 이사하셨다면 npm 12, 아마 여러분 중 일부는 이런 문제를 직접 겪어보셨을 겁니다. 이제 Git 및 원격 URL 의존성이 기본적으로 차단되며, 이에 해당되는 설치 시도는 다음 명령어로 수동으로 허용할 때까지 실패합니다. allow-git 또는 allow-remote. 그건 좋은 기본 설정이며, 오랫동안 기다려온 변화이기도 합니다. 설정하고 싶은 유혹이 들지만 allow-git=all 그러면 파이프라인이 다시 녹색으로 바뀌지만, 그 결과 다시 처음 상태로 돌아가게 됩니다. 다만 이번에는 마치 의도적으로 그렇게 설정한 것처럼 보이게 하는 구성 줄이 하나 추가된 상태죠. 필요한 git 의존성이 모두 본인의 package.json, 그 root value는 예외를 해당 항목들로만 제한하고, 전이적인 예외들은 계속 차단합니다. pnpm 11은 기본 설정 상태에서도 거의 동일한 기능을 제공합니다. 그 blockExoticSubdeps 이제 기본값은 다음과 같이 설정됩니다. true, 이는 전이적 의존성 내의 git 및 tarball 사양을 차단하면서도 사용자가 직접 선언한 사양은 허용합니다.
하지만 그 제어권이 어디에 있는지 주목해 보세요. 데이터를 가져오기를 거부하는 것은 클라이언트이지, 레지스트리에 어떤 내용이 들어있기 때문이 아닙니다. 비공개 레지스트리는 여전히 이러한 종속성을 전혀 인식하지 못합니다. 클라이언트가 단순히 이를 가져오기를 거부할 뿐입니다.
쿼리 결과로 무엇이 나오든, 무엇보다 먼저 그 형태를 살펴보세요. 개발 의존성에 포함된 SHA 핀은 대개 우선순위가 낮은 포함 문제입니다. 런타임 의존성에 포함된 브랜치, 태그 또는 베어 리포지토리 URL은 반드시 수정해야 할 사항이며, 공개된 버전이 있다면 해당 버전으로 전환하거나, 소유하고 있는 범위 지정된 이름 아래에 고정된 사본을 벤더링하여 해결해야 합니다. 그리고 CI 러너가 다음 위치에 접근할 수 있다면 github.com 물론, 매니페스트에 무엇이 적혀 있든 간에 출력 제어가 이러한 가져오기 작업을 감지하므로, 과연 그렇게 해야 할 필요가 있는지 의문을 제기해 볼 만합니다.
그로 인해 거울은 어떤 처지가 되는가
그렇다고 해서 사설 레지스트리가 무의미해지는 것은 아닙니다. 사설 레지스트리는 많은 역할을 수행합니다. 단지 사람들이 그들에게 묻는 질문보다 더 좁은 범위의 질문에 답할 뿐입니다. “이 패키지가 우리가 관리하는 곳에서 왔는가?”라는 질문은 “이 패키지에 필요한 모든 것이 우리가 관리하는 곳에서 왔는가?”라는 질문과 같은 것이 아닙니다.
SecureChain을 개발하는 동안 그 두 번째 질문이 계속해서 제기되었고, 그로 인해 우리가 [ ]를 해석하는 방식이 바뀌었습니다. package.json. 이제 우리는 버전 범위와 마찬가지로 모든 종속성 사양의 형태를 보안 신호로 간주합니다.
만약 귀하의 팀에 npm 쉽게 대체할 수 없는 종속성, 바로 이것이 TuxCare가 해결하고자 하는 문제입니다. SecureChain은 출처 정보가 포함된 소스 코드로부터 재컴파일되어 적극적으로 유지보수되는 패키지를 다루며, Endless Lifecycle Support는 업스트림에서 더 이상 패치를 제공하지 않지만 사용자의 애플리케이션이 여전히 의존하고 있는 지원 종료된 프레임워크를 다룹니다.
자주 묻는 질문
의존성이 레지스트리에서 빠져나간다는 것은 무엇을 의미하나요?
이는 패키지가 이름과 버전 범위 대신 Git URL, 타르볼 URL 또는 다른 원격 위치 중 하나로 의존성을 선언함을 의미합니다. npm ...에 구성된 레지스트리를 통하지 않고, 사양(spec) 내의 위치에서 해당 종속성을 직접 해결합니다. .npmrc, 클라이언트가 해당 소스 유형을 차단하도록 설정되어 있지 않은 한. 어느 쪽이든, 사설 레지스트리나 프록시는 이에 대해 개입할 수 없습니다.
비공개 npm 레지스트리는 git 의존성 문제를 방지해 주나요?
아닙니다. 레지스트리 라우팅은 이름과 버전 범위로 선언된 종속성에만 적용되므로, git 또는 원격 URL 종속성은 절대 비공개 레지스트리를 통과하지 않습니다. 이를 막을 수 있는 것은 npm 클라이언트 자체. npm 기본적으로 12개 블록은 다음을 통해 git 및 원격 URL 종속성을 처리합니다. allow-git 그리고 allow-remote 설정, 이는 레지스트리와는 별개의 제어 항목입니다.
npm 12가 git 의존성을 차단하나요?
네, 기본적으로 그렇습니다. npm 12세트 allow-git 그리고 allow-remote 에 none, 따라서 git 및 원격 URL 의존성(전달 의존성 포함)은 사용자가 명시적으로 허용하지 않는 한 설치에 실패합니다. 설정 allow-git=root 사용자 자신의 파일에서 선언된 git 종속성만 허용합니다. package.json, 반면 all 트리 내 어디에서나 이를 허용합니다.
Git 종속성을 커밋 SHA에 고정해 두는 것이 더 안전한가요?
네, 브랜치나 태그 참조보다 훨씬 안전합니다. 소유자가 커밋 해시를 다른 코드로 지정할 수 없기 때문이죠. 하지만 여전히 레지스트리를 우회하며, 리포지토리가 계속 이용 가능해야 한다는 전제 하에 작동하므로, 이를 무시할 대상이 아니라 추적해야 할 대상으로 간주해야 합니다.
SLSA 출처 정보가 해당 패키지를 안전하게 설치할 수 있다는 뜻인가요?
아니요. SLSA 출처 정보는 해당 아티팩트가 어떻게, 어디서, 어떤 소스를 사용하여 빌드되었는지를 증명합니다. 이는 소스 코드 자체를 보증하지 않으며, 나중에 누군가가 패키지를 설치할 때 레지스트리 외부에서 가져오는 종속성에 대해서는 다루지 않습니다.
