서드파티 스크립트 공급망 공격 방어: SRI 해시·CSP 소스 화이트리스트·npm 의존성 감사로 CDN 오염 막기
핵심 요약
이 글에서 확인할 내용
CDN이 신뢰할 수 없게 된 날: 공급망 공격은 이미 우리 곁에 있다 웹 서비스는 오래전부터 외부 CDN에서 jQuery, Bootstrap, Google Analytics를 불러왔습니다. 빠르고 편리했기 때문입니다. 하지만 UI 개발 현장에서는 그 편리함 뒤에 있는 위험을 진지하게 마주한 것은 2024년 Polyfill.io 사건 이후였습니다.
- 1. 공급망 공격이란 무엇인가: Polyfill.io 사건이 가르쳐준 것
- 2. SRI(Subresource Integrity) 작동 원리: 해시 기반 무결성 검증
- 3. SRI 해시 생성과 <script integrity> 적용
CDN이 신뢰할 수 없게 된 날: 공급망 공격은 이미 우리 곁에 있다
웹 서비스는 오래전부터 외부 CDN에서 jQuery, Bootstrap, Google Analytics를 불러왔습니다. 빠르고 편리했기 때문입니다. 하지만 UI 개발 현장에서는 그 편리함 뒤에 있는 위험을 진지하게 마주한 것은 2024년 Polyfill.io 사건 이후였습니다. 수십만 개의 웹사이트가 아무 의심 없이 불러오던 스크립트가 악성 코드를 포함한 채 배포되기 시작했습니다.
우리가 이 문제를 특히 무겁게 받아들여야 하는 이유는, 서드파티 스크립트가 우리 페이지 안에서 우리 코드와 완전히 동일한 권한으로 실행되기 때문입니다. 브라우저 입장에서 https://our-service.com이 로드한 스크립트는 그 출처가 우리 서버든 외부 CDN이든 구분 없이 같은 오리진의 실행 컨텍스트를 공유합니다. 즉 오염된 결제 SDK 한 줄은 DOM 전체를 읽고, 폼에 입력되는 카드번호를 가로채고, document.cookie에 접근하고, 사용자 몰래 원격 서버로 데이터를 전송할 수 있습니다. 우리 코드가 아무리 견고해도, 신뢰한 서드파티 한 곳이 뚫리면 페이지 전체가 뚫립니다. 이것이 공급망 공격이 XSS보다 다루기 까다로운 이유입니다. XSS는 우리의 입력 처리 실수에서 비롯되지만, 공급망 공격은 우리가 잘못한 것이 하나도 없어도 발생합니다.
이 글은 서드파티 스크립트 공급망 공격의 구조를 방어 관점에서 분석하고, SRI(Subresource Integrity) 해시 검증, CSP script-src 화이트리스트 설계, npm 의존성 감사, Lockfile 무결성 자동화, CI 파이프라인 통합까지 실무 적용 가능한 방어 체계를 단계적으로 정리합니다. 브라우저 보안 레이어 전반에 대해서는 브라우저 보안 강화하기: CSP, Trusted Types, Nonce 기반 스크립트로 XSS 줄이기를 함께 읽으시길 권합니다.
1. 공급망 공격이란 무엇인가: Polyfill.io 사건이 가르쳐준 것
공급망 공격(supply chain attack)은 공격자가 최종 서비스를 직접 겨냥하지 않고, 그 서비스가 신뢰하는 외부 의존성을 오염시키는 방식입니다. 웹 생태계에서 이 공격면은 세 가지로 나뉩니다. 첫째는 CDN에서 제공하는 서드파티 스크립트, 둘째는 npm 레지스트리를 통해 설치하는 패키지, 셋째는 빌드 도구나 CI 환경의 플러그인입니다.
2024년 Polyfill.io 사건은 첫 번째 유형의 전형적인 사례입니다. 해당 도메인의 소유권이 중국 기업에 넘어간 이후, cdn.polyfill.io에서 제공하는 자바스크립트 번들에 모바일 환경에서만 활성화되는 악성 리디렉션 코드가 삽입됐습니다. 문제는 대부분의 사이트가 <script src="https://cdn.polyfill.io/v3/polyfill.min.js"> 한 줄만으로 외부 스크립트를 무조건 신뢰하고 있었다는 것입니다.
이 사건이 특히 교훈적인 이유는 공격이 정적 파일 교체가 아니라 응답 자체를 상황에 따라 다르게 주는 방식이었다는 점입니다. Polyfill.io는 요청하는 브라우저의 User-Agent에 맞춰 필요한 폴리필만 동적으로 조립해 내려주는 서비스였습니다. 즉 URL은 고정이지만 응답 본문은 요청마다 달라질 수 있는 구조였고, 공격자는 이 특성을 악용해 특정 기기·특정 시간대·특정 리퍼러 조건에서만 악성 코드를 섞어 넣었습니다. 개발자가 자기 브라우저로 열어보면 멀쩡하고, 실제 모바일 사용자에게만 악성 코드가 나가는 식입니다. 이런 "조건부 페이로드"는 육안 검수와 샘플링 기반 모니터링을 모두 우회합니다. 뒤에서 다루겠지만, 이것이 바로 응답 내용을 고정 해시로 못박는 SRI가 왜 근본적인 방어책인지를 설명해 줍니다. 참고로 이 사건 당시 Cloudflare와 Fastly는 안전한 정적 미러를 제공했고, 여러 광고·보안 벤더는 해당 도메인 자체를 차단했습니다.
CDN 오염과 결이 다르면서도 같은 뿌리를 가진 것이 Magecart 계열의 웹 스키밍(web skimming) 공격입니다. Magecart는 특정 조직이 아니라 수법을 공유하는 여러 그룹을 통칭하는 이름으로, 전자상거래 사이트의 결제 페이지에 카드정보를 훔치는 자바스크립트 "스키머"를 몰래 심습니다. 이들이 즐겨 쓰는 진입점이 바로 서드파티 스크립트입니다. 사이트가 직접 심은 결제 위젯, 채팅 상담 도구, A/B 테스트 스크립트, 웹 분석 태그 같은 것을 공급자 측에서 오염시키거나, 그 공급자가 또 불러오는 4차 스크립트를 오염시킵니다. 2018년에 대형 항공사와 티켓 판매 사이트가 각각 자사 페이지의 서드파티 스크립트를 통해 수십만 건의 카드정보를 유출당한 사건이 대표적입니다. 스키머는 대개 결제 폼의 입력 이벤트를 후킹해 카드번호·CVC·이름을 실시간으로 수집한 뒤, 공격자가 통제하는 도메인으로 조용히 전송합니다. 이렇게 탈취된 세션과 자격증명이 이후 계정 탈취로 이어지는 흐름은 웹 인증 세션 보안: HttpOnly Cookie, Refresh Token Rotation, CSRF 방어를 함께 설계하기에서 다루는 방어와 정면으로 연결됩니다.
npm 생태계에서는 event-stream 패키지 오염 사건(2018년)이 대표적입니다. 인기 패키지의 유지보수 권한이 악의적인 신규 컨트리뷰터에게 넘어가면서, 특정 암호화폐 지갑 앱에서만 작동하는 악성 코드가 하위 의존성에 은밀히 추가된 사례입니다. 2021년에는 ua-parser-js, coa, rc 패키지가 잇따라 오염되는 사건도 있었습니다. 2024년에는 xz-utils라는 리눅스 압축 라이브러리에 정교하게 은닉된 백도어가 발견됐는데(CVE-2024-3094), 이는 공격자가 수년에 걸쳐 오픈소스 유지보수자의 신뢰를 얻어 커밋 권한을 확보한 뒤 백도어를 심은 사회공학 기반 유지보수자 탈취의 극단적 형태였습니다. 웹 프런트엔드와 직접 관련은 없지만, "패키지 하나의 유지보수 권한이 곧 수많은 다운스트림의 실행 권한"이라는 npm 생태계의 구조적 위험을 그대로 보여줍니다.
이 사례들의 공통점은 하나입니다. 공격자는 코드를 뚫는 게 아니라 신뢰 관계를 뚫습니다. 우리는 CDN 운영자를 신뢰하고, 패키지 유지보수자를 신뢰하고, 그 패키지가 다시 신뢰하는 수백 개의 전이 의존성(transitive dependency)을 암묵적으로 신뢰합니다. 방어의 시작은 "외부에서 오는 것은 모두 의심한다"는 원칙, 그리고 그 의심을 사람의 주의력이 아니라 자동화된 검증 장치로 구현한다는 원칙입니다.
2. SRI(Subresource Integrity) 작동 원리: 해시 기반 무결성 검증
SRI는 브라우저가 외부에서 로드하는 리소스의 내용이 예상한 것과 같은지 암호화 해시로 검증하는 메커니즘입니다. W3C SRI 스펙은 2016년에 권고(Recommendation) 상태가 됐으며, 현재 모든 주요 브라우저가 지원합니다.
MDN 문서에 따르면 SRI는 <script>, <link> 요소에 integrity 속성을 추가하는 방식으로 동작합니다. 브라우저는 리소스를 다운로드한 뒤, 파일의 내용을 지정된 알고리즘으로 해시하고 integrity 값과 비교합니다. 두 값이 일치하지 않으면 브라우저는 해당 리소스의 실행을 거부합니다. 여기서 핵심은 검증이 실행 직전, 네트워크 계층에서 일어난다는 점입니다. 스크립트가 파싱되어 한 줄이라도 실행되기 전에 브라우저가 바이트 단위로 해시를 확인하므로, 오염된 코드는 애초에 실행 컨텍스트에 진입하지 못합니다. 이것이 애플리케이션 코드로 사후 검증하는 방식과 근본적으로 다른 지점입니다.
integrity 값은 알고리즘-Base64인코딩된해시 형태의 문자열입니다. 지원하는 해시 알고리즘은 SHA-256, SHA-384, SHA-512 세 가지입니다. 2026년 기준 실무 권장은 SHA-384 또는 SHA-512입니다. SHA-256도 현재로서는 안전하지만, 스펙은 더 긴 다이제스트를 권장하며 브라우저는 여러 알고리즘이 함께 제시되면 가장 강한 알고리즘을 골라 검증합니다. 알아두면 유용한 규칙이 하나 더 있습니다. 같은 알고리즘의 해시를 공백으로 구분해 여러 개 넣으면, 그중 하나만 일치해도 통과합니다. 이 특성은 무중단 버전 전환에 요긴합니다. 예컨대 리소스를 v2.4.1에서 v2.4.2로 넘길 때 두 버전의 해시를 잠시 나란히 허용해 두면, 캐시가 남은 사용자와 새 사용자 모두 무결성 검증을 통과하면서 안전하게 롤오버할 수 있습니다.
SRI가 크로스 오리진 리소스에서 동작하려면 crossorigin 속성도 필수입니다. 이유는 브라우저의 보안 모델에 있습니다. crossorigin 없이 다른 오리진의 스크립트를 불러오면 응답이 "불투명(opaque)" 상태가 되어, 브라우저는 그 본문 바이트를 읽을 권한이 없습니다. 내용을 읽을 수 없으니 해시를 계산할 수도 없고, 계산할 수 없으니 무결성을 검증할 수도 없습니다. 그래서 crossorigin="anonymous"를 붙여 CORS 요청으로 전환하고, CDN이 Access-Control-Allow-Origin 응답 헤더를 내려줄 때에만 브라우저가 본문을 읽어 해시를 검증합니다. 바꿔 말하면, CDN이 CORS를 지원하지 않으면 SRI 자체를 적용할 수 없습니다. SRI가 CORS 위에 얹혀 있다는 이 사실은, 오리진 검증 설정이 어긋나면 보안 기능이 통째로 무력화된다는 CORS 오설정이 만드는 보안 구멍의 문제의식과 정확히 맞닿아 있습니다. 참고로 같은 오리진에서 불러오는 리소스에는 crossorigin이 필요 없습니다.
SRI의 한계도 명확히 알아야 오용을 피합니다. 첫째, 파일 내용이 바뀌면 해시가 달라지므로 버전이 고정된 불변(immutable) URL에만 적용할 수 있습니다. latest나 v3처럼 내용이 언제든 바뀔 수 있는 "롤링" URL에는 SRI를 걸 수 없습니다. Polyfill.io처럼 응답이 요청마다 달라지는 서비스에 SRI를 적용할 수 없다는 것은, 뒤집어 보면 그런 서비스는 근본적으로 무결성 검증이 불가능한 신뢰 구조라는 뜻입니다. 둘째, SRI는 <script src>와 <link rel=stylesheet>(그리고 preload/modulepreload)에는 걸 수 있지만, 이미지·프레임·워커 등 모든 서브리소스에 일반적으로 적용되지는 않습니다. 셋째이자 가장 중요한 한계는 동적으로 주입되는 스크립트입니다. 이미 로드된 스크립트가 document.createElement('script')로 또 다른 스크립트를 붙이는 경우, 우리는 그 자식 스크립트에 integrity를 지정할 수단이 없습니다. 태그 관리자(GTM)나 광고 스크립트가 대표적으로 이런 방식으로 동작하며, 바로 이 지점을 CSP의 strict-dynamic이 보완합니다. 참고로 fetch()로 리소스를 가져올 때는 fetch(url, { integrity: 'sha384-...' })처럼 요청 옵션으로 무결성 검증을 명시할 수 있어, 우리가 직접 통제하는 동적 로딩 경로에는 SRI를 이어 붙일 수 있습니다.
3. SRI 해시 생성과 <script integrity> 적용
# SHA-384 해시 생성 (Base64 인코딩 포함)
curl -s https://cdn.example.com/libs/payment-sdk@2.4.1/payment.min.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
# 결과 예시: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
<link
rel="stylesheet"
href="https://cdn.example.com/libs/design-tokens@1.3.0/tokens.min.css"
integrity="sha384-abc123def456...생략...xyz789"
crossorigin="anonymous"
/>
<script
src="https://cdn.example.com/libs/payment-sdk@2.4.1/payment.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
defer
></script>
해시를 손으로 뽑는 방법을 이해하는 것은 중요하지만, 운영 환경에서 이 과정을 사람이 수동으로 반복하는 것은 위험합니다. 사람이 해시를 복사·붙여넣기 하는 순간, 그 값이 정말 우리가 신뢰하는 시점의 파일에서 나온 것인지 검증하는 절차가 흐려지기 때문입니다. 그래서 해시 생성은 파이프라인에 고정하는 것이 원칙입니다. Next.js나 Vite 같은 빌드 도구를 사용한다면 빌드 단계에서 SRI 해시를 자동 삽입하는 플러그인을 활용할 수 있습니다. vite-plugin-sri 계열 플러그인이나 프레임워크의 SRI 지원 옵션은 빌드 아티팩트에 대해 자동으로 해시를 생성해 HTML에 주입합니다.
여기서 자주 놓치는 함정 하나. 빌드 도구가 자동 생성하는 SRI는 대개 우리가 직접 번들링해 배포하는 자산(예: _next/static의 청크)에 대한 것입니다. 반면 정말 위험한 것은 우리 손을 떠난 외부 CDN 스크립트입니다. 이 둘을 구분해 관리해야 합니다. 외부 CDN 리소스는 URL과 해시를 별도의 매니페스트 파일(예: sri-hashes.json)로 소스 저장소에 커밋해 두고, 값이 바뀌면 반드시 코드 리뷰를 거치도록 만드는 편이 안전합니다. 이렇게 하면 "언제, 누가, 왜 이 외부 스크립트의 해시를 바꿨는가"가 git 히스토리에 그대로 남습니다. 해시 변경 자체가 리뷰 대상이 되면, 공격자가 몰래 값을 바꿔치기 하기가 훨씬 어려워집니다.
4. CSP script-src 화이트리스트 설계: 'self', nonce, strict-dynamic
SRI는 파일 내용의 무결성을 검증하지만, CSP(Content Security Policy)는 애초에 어떤 출처의 스크립트를 실행할 수 있는지를 정의합니다. 둘은 대체재가 아니라 보완재입니다. SRI가 "이 파일이 내가 아는 그 파일이 맞는가"를 묻는다면, CSP는 "이 위치에서 코드를 실행하는 것이 애초에 허용되는가"를 묻습니다. SRI가 있어도 공격자가 새로운 스크립트 태그를 페이지에 주입할 수 있다면 소용이 없고, CSP가 있어도 허용된 출처의 파일이 통째로 오염되면 막지 못합니다. 그래서 두 겹을 함께 세워야 합니다.
실무에서 script-src 정책을 설계할 때 가장 먼저 피해야 할 것은 'unsafe-inline'과 지나치게 넓은 호스트 허용(https:)입니다. 'unsafe-inline'은 페이지에 주입된 임의의 인라인 스크립트를 모두 허용하므로 CSP의 존재 이유 자체를 무력화합니다. 호스트 기반 화이트리스트도 생각만큼 안전하지 않습니다. 구글 보안팀의 대규모 연구에서 밝혀졌듯이, 화이트리스트에 올린 도메인에 JSONP 엔드포인트나 오래된 프레임워크, 오픈 리다이렉트가 하나라도 있으면 공격자가 그 도메인을 경유해 임의 코드를 실행할 수 있습니다. 우리가 신뢰해 화이트리스트에 넣은 대형 CDN에 그런 우회로가 없다고 장담하기는 어렵습니다. 그래서 현대적 권장 방식은 호스트 목록에 의존하지 않는 nonce + 'strict-dynamic' 조합입니다.
# 1단계: report-only로 관찰
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://cdn.example.com; report-uri /api/csp-report
# 2단계: nonce 기반 정책
Content-Security-Policy: \
default-src 'self'; \
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' \
https://cdn.example.com; \
object-src 'none'; \
base-uri 'self'; \
require-trusted-types-for 'script'; \
report-uri /api/csp-report
# 3단계: SRI 요구 추가
Content-Security-Policy: \
default-src 'self'; \
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' \
https://cdn.example.com; \
require-sri-for script style; \
object-src 'none'; \
report-uri /api/csp-report
nonce(number used once)는 응답마다 새로 생성하는 예측 불가능한 난수입니다. 서버가 매 요청마다 128비트 이상의 암호학적 난수를 생성해 CSP 헤더와 각 <script nonce="..."> 태그에 동일하게 심어 주면, 브라우저는 그 nonce가 붙은 스크립트만 실행합니다. 핵심 전제는 세 가지입니다. 매 응답마다 새 값이어야 하고(재사용 금지), 추측할 수 없어야 하며, 캐시된 HTML에 고정된 nonce가 남지 않아야 합니다. 공격자가 페이지에 인라인 스크립트를 주입해도 올바른 nonce를 알 수 없으므로 실행이 차단됩니다.
'strict-dynamic'은 nonce가 부여된 신뢰 스크립트가 동적으로 추가하는 자식 스크립트를 신뢰하도록 허용합니다. 앞서 SRI의 한계로 지적한 "동적 주입 스크립트" 문제를 여기서 보완하는 것입니다. nonce로 정당성을 인정받은 로더 스크립트가 createElement('script')로 다른 스크립트를 붙이면, 그 자식은 부모로부터 신뢰를 전파받아 실행됩니다. 이 방식의 또 다른 장점은, 'strict-dynamic'이 켜지면 호스트 화이트리스트와 'self'가 (지원 브라우저에서) 무시된다는 점입니다. 즉 앞서 말한 화이트리스트 우회 문제 자체가 사라집니다. 'strict-dynamic'을 이해하지 못하는 구형 브라우저는 이 토큰을 무시하고 호스트 목록으로 폴백하므로, 두 방식을 함께 적어 두면 하위 호환성도 유지됩니다.
한 가지 정확히 짚을 것이 있습니다. 위 3단계 예시의 require-sri-for 디렉티브는 한때 CSP에서 SRI를 강제하는 수단으로 제안됐지만, 브라우저에 널리 채택되지 못하고 사실상 폐기 수순을 밟았습니다. 2026년 현재 이 디렉티브의 브라우저 지원은 신뢰할 수 없으므로, "모든 스크립트에 무결성을 강제한다"는 목표를 이 하나에 의존해서는 안 됩니다. 대신 SRI는 빌드·리뷰 파이프라인에서 강제하고, CSP는 nonce/strict-dynamic으로 실행 출처를 통제하는 역할 분담이 현실적입니다. 그리고 require-trusted-types-for 'script'는 DOM XSS 싱크를 차단하는 별개의 강력한 장치로, 브라우저 보안 강화하기 글에서 더 깊이 다룹니다.
CSP는 관찰부터 시작하는 것이 철칙입니다. 처음부터 강제 모드로 배포하면 정상 스크립트까지 깨져 서비스가 망가집니다. Content-Security-Policy-Report-Only 헤더로 위반만 리포트하게 두고 실제 트래픽에서 어떤 스크립트가 어떤 출처에서 실행되는지 며칠 관찰한 뒤, 리포트가 잦아들면 강제 모드로 승격하는 단계적 롤아웃이 안전합니다.
5. npm audit과 Snyk으로 의존성 취약점 탐지
CDN이 런타임의 공격면이라면, npm은 빌드 타임의 공격면입니다. 우리가 package.json에 직접 적은 의존성은 수십 개일지 몰라도, 그것이 끌어오는 전이 의존성까지 합치면 수백에서 수천 개에 이릅니다. 이 거대한 트리 어딘가에 알려진 취약점이나 악성 코드가 있는지 사람이 일일이 확인하는 것은 불가능하며, 도구의 도움이 반드시 필요합니다.
npm audit은 npm이 참조하는 취약점 데이터베이스(GitHub Advisory Database)와 현재 package-lock.json을 대조해 알려진 취약점을 보고합니다.
npm audit
npm audit --json
npm audit --audit-level=high
npm audit fix
npm audit의 결과를 읽을 때는 심각도(critical/high/moderate/low)와 함께 그 취약점이 실제로 우리 실행 경로에 닿는가를 판단해야 합니다. 빌드 도구의 개발 의존성(devDependencies)에만 있는 취약점과, 프로덕션 번들에 실제로 포함되는 런타임 의존성의 취약점은 위험도가 전혀 다릅니다. 무조건 숫자 0을 목표로 삼으면 실질적 위험이 없는 항목에 매달려 정작 중요한 것을 놓치기 쉽습니다. 한편 npm audit fix --force는 메이저 버전 업그레이드를 포함할 수 있어 breaking change를 유발합니다. 자동 수정은 반드시 별도 브랜치에서 실행하고 테스트로 회귀를 확인한 뒤 병합해야 합니다.
npm audit의 태생적 한계도 이해해야 합니다. 이 도구는 이미 공개적으로 알려진(공시된) 취약점을 찾는 데 강하지만, event-stream 사례처럼 아직 아무도 신고하지 않은 신종 악성 패키지를 탐지하는 데는 약합니다. 공시 시점과 실제 오염 시점 사이에는 공백이 있고, 공급망 공격은 바로 그 공백을 노립니다. 이 지점을 보완하기 위해 Snyk 같은 상용 도구나, 뒤에서 다룰 provenance·서명 검증을 함께 씁니다.
| 기준 | npm audit | Snyk |
|---|---|---|
| 데이터베이스 | GitHub Advisory Database | Snyk 전용 DB |
| 악성 패키지 탐지 | 제한적 | 지원 |
| 자동 PR 생성 | 미지원 | 지원 |
| 라이선스 정책 검사 | 미지원 | 지원 |
| 무료 플랜 | 완전 무료 | 오픈소스 한정 |
도구를 무엇으로 고르든, 검증 로직의 부재 자체가 취약점이라는 원칙은 바뀌지 않습니다. 입력을 신뢰하지 않고 반드시 검증 파이프라인을 통과시킨다는 사고방식은 JWT 클레임 검증 누락이나 LLM 가드레일과 프롬프트 인젝션 방어: OWASP LLM Top 10 기반 입력·출력 검증 파이프라인 설계에서 다루는 방어 사고와 완전히 동일한 뿌리에서 나옵니다.
6. Lockfile 무결성 검증 자동화, 버전 고정, provenance
Lockfile은 의존성 공급망 공격의 마지막 방어선 중 하나입니다. package-lock.json이 단순히 버전 번호만 적어 두는 파일이 아니라는 점을 이해하는 것이 중요합니다. 이 파일은 설치된 각 패키지 tarball의 무결성 해시(integrity 필드, 통상 sha512)를 함께 기록합니다. 말하자면 npm판 SRI입니다. npm ci는 레지스트리에서 내려받은 패키지의 실제 해시를 lockfile에 적힌 해시와 대조하고, 하나라도 어긋나면 설치를 중단합니다. 누군가 레지스트리에서 특정 버전의 tarball 내용을 몰래 바꿔치기 하더라도, 우리 lockfile의 해시와 맞지 않으면 CI가 즉시 실패한다는 뜻입니다.
# npm ci: lockfile을 엄격히 따름
npm ci
# pnpm
pnpm install --frozen-lockfile
# yarn
yarn install --immutable
npm install은 package.json의 semver 범위(예: ^2.4.0) 안에서 더 최신 버전을 설치하려 할 수 있고, 그 과정에서 lockfile을 조용히 갱신할 수 있습니다. 반면 npm ci는 lockfile에 기록된 정확한 버전과 해시만 설치하고, lockfile이 없거나 package.json과 불일치하면 즉시 실패합니다. 그래서 CI에서는 예외 없이 npm ci(또는 pnpm의 --frozen-lockfile, yarn의 --immutable)를 써야 합니다. 개발자의 로컬에서만 npm install을 허용하고, 자동화 환경에서는 결코 semver 범위를 다시 해석하지 않게 만드는 것이 원칙입니다.
버전 고정(pinning) 전략도 함께 봐야 합니다. ^나 ~ 범위를 넓게 열어 두면, 공격자가 오염된 패치 버전을 새로 배포했을 때 다음 설치에서 자동으로 끌려 들어올 창이 생깁니다. 이를 좁히는 방법은 두 가지입니다. 첫째, 직접 의존성의 버전을 정확히 고정하거나(save-exact), 둘째, 전이 의존성까지 못박아야 할 때는 overrides(npm)·resolutions(yarn)·overrides(pnpm)로 특정 하위 패키지의 버전을 강제하는 것입니다. 물론 너무 조이면 정당한 보안 패치까지 막히므로, 고정과 갱신 사이의 균형은 정기적인 의존성 업데이트 리듬(예: 주간 Dependabot/Renovate PR)으로 관리합니다.
마지막 한 겹은 출처 증명(provenance)과 서명 검증입니다. npm은 Sigstore 기반의 provenance를 지원해, 패키지가 어떤 소스 저장소의 어떤 커밋에서, 어떤 CI 워크플로로 빌드됐는지를 검증 가능한 증명으로 남길 수 있습니다(npm publish --provenance). 소비자 입장에서는 npm audit signatures로 설치한 패키지들의 레지스트리 서명을 검증할 수 있습니다. provenance가 있으면 "이 tarball이 정말 그 오픈소스 저장소에서 나온 것이 맞는가"를 사람의 신뢰가 아니라 암호학적 증거로 확인하게 됩니다. SLSA 같은 공급망 무결성 프레임워크가 지향하는 방향이 바로 이것입니다. 아직 모든 패키지가 provenance를 제공하지는 않지만, 우리가 직접 배포하는 내부 패키지부터 provenance를 켜 두는 것은 지금 당장 실천할 수 있는 좋은 출발점입니다.

7. CI 파이프라인 통합 전략: PR 차단 vs 알림 모드
지금까지의 방어 장치는 CI 파이프라인에 통합될 때 비로소 실효를 갖습니다. 사람이 기억해서 가끔 돌리는 검사는 반드시 잊히지만, 파이프라인에 박힌 검사는 잊히지 않기 때문입니다.
name: Security Audit
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * *'
jobs:
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: npm audit
run: npm audit --audit-level=high --json > audit-report.json || true
- name: Parse audit results
id: audit
run: |
CRITICAL=$(cat audit-report.json | jq '.metadata.vulnerabilities.critical // 0')
HIGH=$(cat audit-report.json | jq '.metadata.vulnerabilities.high // 0')
echo "critical=${CRITICAL}" >> $GITHUB_OUTPUT
echo "high=${HIGH}" >> $GITHUB_OUTPUT
- name: Fail on critical vulnerabilities
if: steps.audit.outputs.critical > 0
run: exit 1
- name: SRI hash verification for known CDN resources
run: node scripts/verify-sri-hashes.js
이 워크플로에서 눈여겨볼 설계 결정이 몇 가지 있습니다. 우선 schedule 트리거로 매일 정기 감사를 돌리는 것이 핵심입니다. 공급망 취약점은 우리 코드가 바뀌지 않아도 새로 공시됩니다. 어제까지 깨끗하던 의존성이 오늘 아침 critical로 바뀔 수 있으므로, 코드 변경과 무관하게 매일 트리를 다시 스캔해야 신규 취약점을 놓치지 않습니다. 다음으로 --audit-level=high 결과를 파싱해 critical과 high를 다르게 취급하는 부분입니다. critical 취약점은 항상 PR을 차단하고, high는 알림만 보내는 혼합 전략이 실용적입니다. 모든 취약점을 무조건 차단하면 배포가 상시 막혀 팀이 검사 자체를 우회하려 들고, 반대로 아무것도 차단하지 않으면 경고는 소음이 되어 무시됩니다. 심각도에 따라 게이트의 강도를 나누는 것이 지속 가능한 정책입니다.
마지막 단계인 SRI 해시 검증 스크립트는 CDN 리소스가 우리가 커밋해 둔 매니페스트의 해시와 여전히 일치하는지를 능동적으로 확인합니다. 이것이 앞의 8번 절에서 다루는 능동 탐지의 실체입니다.
8. 인시던트 시뮬레이션: 오염된 CDN 라이브러리 탐지하기
방어를 세웠다면, 그 방어가 실제로 오염을 잡아내는지를 시나리오로 점검해야 합니다. 탐지는 서로 다른 층위에서 독립적으로 작동하는 여러 겹으로 설계하는 것이 좋습니다.
탐지 레이어 1: SRI 해시 불일치 — 오염된 파일이 브라우저에 도달하면 해시가 어긋나 브라우저가 즉시 실행을 차단하고, 동시에 CSP 위반 리포트가 전송됩니다. 이 층은 사용자 브라우저에서 실시간으로, 사후가 아니라 실행 이전에 작동한다는 점에서 가장 강력합니다.
탐지 레이어 2: CSP 위반 리포트 급증 — 평소에는 잔잔하던 위반 리포트가 갑자기 폭증한다면, 특정 스크립트가 대량으로 차단되고 있다는 신호입니다. 이때 필요한 것이 리포트를 수집·집계하는 파이프라인입니다. 브라우저는 report-uri 디렉티브가 지정한 엔드포인트로 application/csp-report JSON을 POST하는데, 이 방식은 현재 신형 Reporting API의 report-to(그리고 Reporting-Endpoints 응답 헤더)로 이전되는 중입니다. 브라우저별 지원 편차가 아직 남아 있으므로, 2026년 실무에서는 report-uri와 report-to를 함께 명시해 폭넓게 수집하는 편이 안전합니다. 수집한 리포트에서 blocked-uri, violated-directive, document-uri를 집계해 임계치 알림을 걸어 두면, 특정 CDN 스크립트가 갑자기 차단되기 시작하는 순간을 사람보다 먼저 알아챌 수 있습니다.
탐지 레이어 3: CI 검증 스크립트 — 사용자 브라우저에 도달하기 전에, 우리 쪽에서 능동적으로 CDN 리소스를 주기적으로 내려받아 해시를 재계산하고 매니페스트와 대조합니다. 이 층은 사용자 트래픽에 의존하지 않으므로, 트래픽이 적은 시간대의 은밀한 오염도 잡아낼 수 있습니다.
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const sriManifest = JSON.parse(readFileSync('./sri-hashes.json', 'utf8'));
async function verifySriHashes(manifest) {
const violations = [];
for (const [url, expectedHash] of Object.entries(manifest)) {
const [algorithm, expected] = expectedHash.split('-');
const response = await fetch(url);
if (!response.ok) continue;
const buffer = await response.arrayBuffer();
const computed = createHash(algorithm)
.update(Buffer.from(buffer))
.digest('base64');
const hashString = `${algorithm}-${computed}`;
if (hashString !== expectedHash) {
console.error(`[SRI VIOLATION] ${url}`);
violations.push({ url, expected: expectedHash, computed: hashString });
}
}
if (violations.length > 0) {
console.error(`\n${violations.length}개의 SRI 해시 불일치 발견`);
process.exit(1);
}
}
verifySriHashes(sriManifest);
JWT 클레임 검증 누락과 마찬가지로, 공급망 공격도 검증 로직의 부재가 핵심 취약점입니다. 세 층 중 어느 하나라도 있으면 완전한 무방비 상태보다는 낫지만, 세 층이 함께 있을 때에야 "브라우저 차단(레이어 1) → 리포트 폭증 감지(레이어 2) → CI 능동 검증(레이어 3)"으로 이어지는 촘촘한 그물이 완성됩니다.
9. 위협 모델별 방어 조합: 무엇으로 무엇을 막는가
지금까지 다룬 장치들은 각기 다른 공격 시나리오에 대응합니다. 어떤 위협에 어떤 방어가 유효한지를 명확히 매핑해 두면, 우리 서비스에 필요한 조합을 결정하기 쉽습니다.
시나리오 A — 정적 CDN 파일이 통째로 교체됨. 공격자가 우리가 참조하는 고정 URL의 파일 내용을 바꿔치기 한 경우입니다. SRI 해시가 즉시 불일치를 감지해 실행을 차단합니다. 가장 이상적으로 SRI가 작동하는 경우이며, 여기서는 SRI가 1차 방어선입니다.
시나리오 B — 응답이 조건부로 바뀌는 동적 CDN(Polyfill.io 유형). URL은 고정이지만 응답이 요청마다 달라지므로 애초에 SRI를 걸 수 없습니다. 이 경우 근본 대책은 그 서비스를 자체 호스팅하거나 신뢰할 수 있는 정적 미러로 전환해 무결성 검증이 가능한 형태로 바꾸는 것입니다. 조건부 페이로드 서비스는 "SRI 불가능"이라는 사실 자체가 위험 신호로 다뤄져야 합니다.
시나리오 C — 새로운 악성 스크립트 태그가 페이지에 주입됨(스키밍/XSS 연계). SRI는 우리가 미리 알고 있는 리소스에만 적용되므로, 공격자가 새로 삽입한 태그는 SRI로 막을 수 없습니다. 이때는 CSP의 nonce와 'strict-dynamic'이 방어선입니다. 올바른 nonce 없이는 인라인·주입 스크립트가 실행되지 못합니다.
시나리오 D — npm 의존성에 알려진 취약점 또는 악성 코드가 유입됨. 런타임이 아니라 빌드 타임의 문제이므로 SRI·CSP로는 막을 수 없습니다. 여기서는 npm audit, lockfile 무결성(npm ci), 버전 고정, provenance 검증이 방어선입니다.
시나리오 E — 레지스트리에서 tarball이 은밀히 교체됨. lockfile의 integrity 해시가 불일치를 감지해 npm ci가 실패합니다. lockfile을 커밋하고 CI에서 npm ci를 강제하는 것이 이 시나리오의 핵심 방어입니다.
이 매핑에서 드러나는 결론은 분명합니다. 어느 한 장치도 모든 시나리오를 커버하지 못합니다. SRI는 B·C·D·E를 막지 못하고, CSP는 D·E를 막지 못하며, npm audit은 A·B·C를 막지 못합니다. 방어는 조합으로만 완성됩니다. 그리고 이 다층 방어의 사고방식은 인증(OAuth 2.0 PKCE 흐름 완전 해부)이든 세션 관리든 동일하게 적용되는 보안의 기본 원리입니다.
10. 실전 운영 체크리스트
SRI 적용 현황: 모든 외부 CDN 스크립트와 스타일시트에 integrity와 crossorigin 속성이 있는지 확인합니다. 버전이 고정된 불변 URL을 참조하는지, 롤링 URL을 쓰고 있지는 않은지 함께 점검합니다.
CSP 정책 유효성: 보안 헤더 분석 도구로 현재 CSP를 정기적으로 평가합니다. 'unsafe-inline'이 남아 있지 않은지, 호스트 화이트리스트에 과도하게 의존하고 있지 않은지, nonce가 응답마다 새로 생성되는지 확인합니다.
npm 의존성 상태: npm audit 또는 Snyk으로 현재 critical과 high 취약점 수를 확인하고, 각 취약점이 실제 실행 경로에 닿는지 판단합니다. OWASP 서드파티 자바스크립트 관리 치트시트를 참고합니다.
Lockfile 관리: package-lock.json이 커밋돼 있고, CI 파이프라인에서 npm ci가 사용되는지 확인합니다. lockfile의 integrity 해시가 검증되고 있는지도 점검합니다.
인시던트 대응 준비: 오염된 CDN 리소스가 발견됐을 때의 대응 절차(긴급 해시 롤백, 스크립트 제거 배포, 사용자 영향 범위 산정)를 문서화하고, CSP 리포트 급증 시 알림이 실제로 사람에게 전달되는지 주기적으로 리허설합니다.
자주 묻는 질문(FAQ)
Q. SRI만 잘 걸어 두면 CSP는 없어도 되나요? 아닙니다. SRI는 우리가 이미 알고 있는 특정 리소스의 내용 변조만 막습니다. 공격자가 XSS나 스키밍으로 새로운 스크립트 태그를 주입하면 SRI는 관여하지 못합니다. 그 주입을 막는 것은 CSP의 몫입니다. 반대로 CSP만으로 허용된 출처의 파일이 통째로 오염되는 것은 SRI만이 잡습니다. 둘은 항상 함께 갑니다.
Q. 자주 갱신되는 서드파티 위젯(채팅, 분석 등)은 SRI를 어떻게 걸죠? 그런 리소스는 대개 롤링 URL을 쓰거나 응답이 수시로 바뀌므로 SRI를 직접 걸기 어렵습니다. 현실적 대안은 두 가지입니다. 벤더가 버전 고정 URL을 제공하면 그것을 쓰고 정기적으로 해시를 갱신하는 파이프라인을 두거나, 그것이 불가능하면 nonce/'strict-dynamic' 기반 CSP로 실행 출처를 통제하고 CSP 리포트로 이상 징후를 감시하는 방식으로 방어의 무게중심을 옮깁니다.
Q. npm audit이 취약점 0을 보고하면 안전한가요? 안전을 보장하지 않습니다. npm audit은 이미 공시된 취약점만 봅니다. 아직 신고되지 않은 신종 악성 패키지나 유지보수자 탈취는 잡지 못합니다. 그래서 lockfile 무결성, 버전 고정, provenance 검증, 그리고 새 의존성 추가 시의 코드 리뷰를 함께 운영해야 합니다.
Q. CSP를 강제 모드로 켰더니 정상 스크립트까지 깨졌습니다. 강제 모드로 바로 배포한 것이 원인일 가능성이 큽니다. 반드시 Content-Security-Policy-Report-Only로 며칠 관찰해 위반 리포트를 수집·정리한 뒤, 정상 스크립트를 모두 nonce나 허용 출처로 반영한 상태에서 강제 모드로 승격해야 합니다.
언제 이 방어를 과하게 적용하지 말아야 하나
모든 방어에는 운영 비용이 따릅니다. 외부 의존성이 거의 없는 순수 정적 사이트, 서드파티 스크립트를 단 하나도 쓰지 않는 내부 관리 도구라면 SRI 매니페스트와 CSP nonce 인프라를 처음부터 무겁게 구축하는 것이 과할 수 있습니다. 그러나 결제·로그인·개인정보 입력이 있는 페이지, 서드파티 태그가 여러 개 올라간 커머스 프런트엔드라면 이야기가 완전히 달라집니다. 스키밍 공격의 피해가 곧바로 금전·신뢰 손실로 직결되기 때문입니다. 방어의 강도는 "우리가 무엇을 잃을 수 있는가"에 비례해 설계하되, 최소한 lockfile 무결성과 npm ci, 정기 의존성 감사만큼은 규모와 무관하게 기본으로 갖추기를 권합니다. 이 셋은 비용이 거의 들지 않으면서 시나리오 D·E를 통째로 막아 주는, 투자 대비 효과가 가장 확실한 방어이기 때문입니다.
한 번 더 짚기
- SRI 해시를 모든 외부 CDN 리소스에 적용하고, sri-hashes.json을 버전 관리에 포함합니다.
- CSP
script-src에'unsafe-inline'없이 nonce 기반 정책과 허용 출처 최소화를 유지합니다. - CI에서
npm ci로 Lockfile을 엄격히 따르고,npm audit --audit-level=high로critical취약점은 PR을 차단합니다. - 스케줄 기반 의존성 감사로 신규 취약점을 놓치지 않습니다.
- 오염 탐지 시 즉시 해당 스크립트를 제거하는 긴급 배포 절차를 준비합니다.
SRI와 CSP, npm audit은 각각 다른 공격 시나리오를 담당합니다. 어느 하나도 단독으로는 공급망 공격을 막지 못하지만, 함께 동작할 때 비로소 CDN 오염부터 레지스트리 변조까지 이어지는 공격면을 의미 있게 좁힙니다. 우리가 잘못한 것이 없어도 공격은 발생할 수 있다는 전제에서 출발해, 신뢰를 사람의 주의력이 아니라 자동화된 검증 장치로 대체하는 것 — 그것이 공급망 방어의 핵심입니다.