Files
arasystem/.claude/rules/security.md
T
ttipo 65c0490675
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m38s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m24s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m47s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m56s
docs(rules): 의존성 유예 표를 11-05 점검으로 고친다
올린 넷을 표에서 빼고, 남은 운영 권고(swagger 아래 js-yaml · typeorm 아래 glob · minimatch)와
개발 전용 묶음의 다시 볼 시점을 적는다. multer override 는 platform-express 를 v12.3 이상으로
올릴 때 지운다(nestjs 스킬도 같이). npm audit fix 를 통째로 돌리지 않는 까닭을 함께 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:39:38 +09:00

25 KiB

보안

비밀 정보

  • .env* 등 민감정보 Git 커밋 금지 (.gitignore 확인) — 예시는 *.example(예: .env.development.example, .env.example) 로만 공유
  • *.example 파일의 값은 반드시 더미 placeholder 여야 한다. 실제로 쓰는(또는 쓸 법한) 비밀번호·키를 예시 파일에 적지 않는다.
    • ❌ DB_PASSWORD=dev_960426
    • ✅ DB_PASSWORD=change-me-in-local-env
  • 시크릿이 한 번이라도 커밋되었다면 값을 교체(rotate) 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다.
  • 로그·에러 응답에 시크릿을 남기지 않는다 (logging).

인증 · 토큰

  • JWT는 HttpOnly·Secure 쿠키 사용 (웹 localStorage 금지) / 모바일은 flutter_secure_storage 사용 (키: jwt_token)
  • Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
  • 리프레시 토큰은 서버 세션에 해시만 저장하고 1회용으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다. 동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
  • 로그인 실패 한도는 자격 확인 전에 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다. 확인 뒤에 세면 동시에 들어온 요청 수백 건이 모두 한도 검사를 통과한다 (구현 nestjs)
  • 비밀번호는 단방향 해시(bcrypt/argon2)로 저장한다. 평문·가역 암호화 금지
  • 메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다 (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다. 백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 (nestjs)
  • 2단계 인증(12단계 — 엔진 TOTP): 엔진은 등록 때 코드를 확인하지 않고(잘못 등록하면 직원이 끄지도 못한다) 같은 코드를 다시 받으므로 서버가 막는다 — 등록 때 첫 코드 확인, 성공에 쓴 코드를 2분 동안 다시 받지 않기(확인하는 동안에도 — 같은 코드의 동시 요청이 둘 다 지나가지 않게), 코드 실패를 따로 세기(같은 주소 15분 5회 · 24시간 20회). 코드 단계에서도 비밀번호를 먼저 엔진 Basic 으로 본다 — 엔진 /api/auth 의 failure 는 비밀번호 · 코드를 가르지 않아, 그대로면 비밀번호 추측이 코드 실패로 세진다. 비밀(TOTP 키)은 등록 10분 동안만 서버 메모리에 두고 켠 뒤에는 엔진에만 있다
  • 앱 비밀번호(메일 프로그램용 — 엔진이 만든 40자): 웹메일 로그인 · 비밀번호 바꾸기는 그 모양(app + 1자 + 36자)을 엔진에 묻지 않고 거절한다(엔진에는 웹메일에서 빼는 설정이 없다). 만들 때 권한을 메일 프로그램만(Replace — authenticate · imapAuthenticate · emailSend)으로 좁힌다 — 기본(Inherit)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만든다. 비밀번호를 바꾸면 앱 비밀번호를 모두 지운다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게). 값은 만든 응답에만 싣는다(no-store — 로그 · DB 에 두지 않는다)
  • 끊은 세션은 바로 막는다(12단계) — 액세스 토큰의 sid 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
  • 엔진이 부르는 길(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 /api/internal/ 을 닫고, 서버가 접속 주소(소켓 — X-Forwarded-For 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고, 본문 원문의 HMAC-SHA256(MAIL_WEBHOOK_SECRET)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
  • 제한된 세션(13단계 — 새 비밀번호 정하기 · 2단계 인증 켜기 전): 로그인은 되지만 허용한 경로(@AllowRestrictedSession — /api/auth/* · 2단계 켜기)만 지나고 그 밖은 403 AUTH_010 이다. 제한을 푸는 요청은 그 요청의 세션만 풀고, 다음 제한이 있으면 그 제한으로 바꾼다. 2단계를 켜면 그 직원의 다른 세션 가운데 2단계를 거친 적 없는(twoFactorSetup) 세션은 끊는다 — 비밀번호만 아는 다른 사람의 세션이 함께 열리지 않게(2026-11-02 리뷰). 엔진은 2단계를 강제하지 못하고, 비밀번호가 만료되면 웹메일(대리 접근)은 메일 오류만 보이므로 서버가 알아채 그 단계로 보낸다
  • 운영 도구(서버 안 CLI — 13단계)의 관리자 비밀번호는 실행할 때 터미널에서만 받는다 — 화면에 보이지 않게 · 저장하지 않음 · 실행 기록 · 로그 · 예외 문장 · 프로세스 인자에 없음 · 입력 기록(↑) 0(historySize: 0 — 2026-11-02 리뷰). 표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 OPS_ALLOW_NO_TTY=1). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다. 이전용 비밀번호(메일 이전 — 2026-11-02)도 같은 무작위 · 한 번만이고 엔진 만료를 30일 뒤로 건다(운영 도구 한 번 실행 안에서는 같은 값 — 프로세스 메모리에만) — imapsync 의 비밀번호 파일(--passfile — 0600, 끝나면 지움)에만 넣고 명령줄에 싣거나 직원에게 주지 않는다. 마지막 imapsync 뒤 「이전 마치기」로 직원용 임시 비밀번호로 바꾼다
  • 메일 엔진 관리자 계정(13단계 setup): 대리 접근 권한(impersonate)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다. 되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄)
  • 받은편지함 알림 메일(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다
  • 퇴사 마무리(주소를 다시 쓸 수 있게): 엔진 계정을 지운 뒤 우리 DB 에서 그 주소로 키링한 행을 모두 지운다 — 남기면 같은 주소의 새 사람에게 옛 로그인 기록 · 2단계 설정 · 서명이 보인다. 끊은 세션 행은 끊은 기록을 서버가 동기화로 읽어야 하므로 바로 지우지 않는다(정리 cron)
  • 인증 실패 응답에서 원인을 구분해 알려주지 않는다 (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
    • 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다. 로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.

입력 · 출력

  • 입력값은 DTO(class-validator) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 ValidationPipe 의 whitelist/forbidNonWhitelisted 를 끄지 않는다
  • JSON 본문은 DTO 에 닿기 전에 모양부터 잰다 — 배열 하나 1,000개 · 값 10,000개 · 깊이 32단, UTF-8 만 (app.setup.ts 의 JSON_SHAPE_LIMITS, shared/utils/json-shape.ts). DTO 의 개수 한도로는 막지 못한다 — 변환이 항목마다 객체를 만들고, 검사는 개수 한도에 걸려도 항목마다 중첩 검사를 해서 1MB 본문 하나로 서버가 수 초 멈췄다 (2026-10-28 리뷰). class-validator 의 ArrayUnique 는 항목 수의 제곱으로 느려 쓰지 않는다 (common/decorators/array-distinct.decorator.ts 의 ArrayDistinct)
  • TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
  • 외부로 나가는 데이터(보내는 메일의 헤더 등)에 비교용 지문을 실어야 하면 평범한 해시가 아니라 받는 쪽이 모르는 비밀로 HMAC 한다 (예: 메일마다 다른 무작위 Idempotency-Key). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
    • 서버 서명 키(JWT_SECRET 등)나 거기서 파생한 키를 쓰지 않는다 — 평문을 아는 받은 사람이 (평문, HMAC) 쌍으로 서명 키 후보를 오프라인에서 대입해 볼 수 있고, 맞히면 토큰을 위조할 수 있다 (2026-10-26 리뷰)
  • 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 SYS_001 로 수렴시킨다 (error-codes)
  • 사용자가 쓴 HTML(메일 서식 본문)은 서버가 허용 목록으로 다시 정화한 뒤 내보낸다 — 클라이언트 편집기의 출력을 믿지 않는다. style 은 속성마다 값 형식을 처음부터 끝까지(^…$) 맞춘다 (modules/mail/html/sanitize-outgoing-html.ts, 계약 2절 "서식 본문")
  • 받은 메일·서식 본문처럼 밖에서 온 HTML 은 정화하기 전에 비용부터 잰다 — 정화기(htmlparser2)는 중첩 깊이의 제곱으로 느려져 메일 한 통으로 서버 전체를 수 분 멈출 수 있다 (2026-10-27 리뷰). 넘으면 정화하지 않는다 (modules/mail/html/html-parse-budget.ts, nestjs)
  • 브라우저에서 외부 HTML 을 고쳐 쓸 때는 떨어진 문서(<template>·DOMParser) 안에서 끝낸다 — 화면 문서로 옮기면 img onerror 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 (vue3 체크리스트 "외부 HTML")

파일 업로드

템플릿은 modules/upload 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.

  • 확장자와 MIME 을 둘 다 검사한다 (upload.config.ts). 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
    • 예외 — 대용량 첨부(11단계 modules/file-storage)는 확장자 허용 목록만 본다. 브라우저가 주는 형식은 dwg · psd · 7z 같은 파일에 믿을 근거가 없어(비거나 제각각) MIME 으로 거르면 정상 파일만 막힌다. 대신 세 겹으로 막는다: 내려줄 때 늘 application/octet-stream + attachment + nosniff + CSP sandbox(브라우저가 열지 않는다), 허용 목록에 브라우저가 실행하는 형식이 없다, 내용은 ClamAV 가 본다(검사 전 · 중에는 받을 수 없다) — 계약 4절 "정한 것" 10.
  • 허용 목록(allowlist) 방식으로만 받는다. 차단 목록은 언제나 빠뜨린다. .html·.svg·.js 등 브라우저가 실행할 수 있는 형식은 넣지 않는다.
  • 저장 파일명은 서버가 생성한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면 ../../etc/passwd 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
  • 크기 한도를 설정한다 (UPLOAD_MAX_SIZE_MB, 기본 5MB). 초과 시 FILE_002(413).
  • 큰 업로드(메일 첨부 등)는 메모리가 아니라 임시 디스크로 받아 스트리밍으로 넘기고, 성공·실패와 관계없이 지운다. 메모리에 받으면 동시 업로드 수 × 한도만큼 상주해 컨테이너가 OOM 으로 죽고 모든 요청이 끊긴다. 임시 디스크를 tmpfs 로 두면 메모리 한도에 잡혀 같은 문제가 된다.
  • 업로드 디렉터리를 정적 파일로 그대로 서빙하지 않는다. 내려줄 때는 컨트롤러를 거쳐 Content-Type 을 서버가 지정하고 Content-Disposition: attachment 를 붙인다.

⚠️ 알려진 한계: 확장자·MIME 검사는 파일 내용을 보지 않는다. 이름이 .png 이고 MIME 이 image/png 이면 내용이 HTML 이어도 통과한다. 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면 매직 바이트를 확인하는 라이브러리(file-type 등)를 파이프라인에 추가한다. (서명 그림은 매직 바이트로 판별하고 — modules/mail-signature/signature-image-format.ts, 대용량 첨부는 악성 검사(ClamAV)가 내용을 본다.)

공개 링크 (로그인 없이 받는 길 — 11단계 대용량 첨부)

  • 토큰은 서버가 만든 256비트(randomBytes(32) base64url)이고 DB 에는 SHA-256 만 둔다. 모양이 아니면 DB 에 묻지 않는다. 원문 토큰은 로그 · 오류 context · 목록 응답 어디에도 두지 않는다 — 요청 주소는 maskUrlForLog()(경로 어디서든 public-links 뒤 조각을 가린다), 앞단 nginx 접근 로그도 가린다.
  • 공개 경로는 따로 둔 공개 호스트(SHARE_PUBLIC_URL — files.)에서만 열고 포털에서는 닫는다(nginx 의 호스트별 server + 백엔드 public-host.ts 두 겹). 인증 쿠키는 host-only 라 공개 호스트로 가지 않는다. 운영에서 공개 호스트를 CORS_ORIGIN 에 넣으면 기동하지 않는다(분리가 꺼지므로).
  • 받은 수 상한은 한 문장의 조건부 UPDATE(… WHERE count < limit RETURNING)로 센다 — 읽고 나서 쓰면 동시 요청이 상한을 넘는다.
  • 버퍼링을 끈 내려받기는 연결 하나가 앞단 연결 둘과 백엔드 fd 를 잡는다 — IP 마다 · 전체 동시 연결 상한(nginx limit_conn)을 함께 둔다. 전체 상한은 경로(공개 · 앱 안)마다 따로 — 함께 세면 바깥 주소 몇십 개로 직원의 받기까지 막힌다. 앞단과 백엔드가 같은 경로를 같게 읽어야 한다 — 백엔드가 대소문자를 가리지 않던 때 /FILES/ 처럼 글자 하나로 nginx 의 상한 location 을 비켜 갔다(2026-10-30 리뷰). 그래서 nginx 의 받기 location 은 대소문자를 가리지 않는 정규식이고, 백엔드 라우팅은 대소문자를 가린다(두 겹 — nestjs "전역 HTTP 설정"). 같은 까닭으로 요청 대상의 날 \(경로) · # · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 url.parse 로 다시 읽어 \ 를 / 로 바꿔, nginx 가 닫은 /api/internal/ · /api/health 가 백엔드에서 열렸다(2026-10-31 리뷰).

네트워크 · 인프라

  • 보안 헤더(helmet) 적용
  • CORS 는 화이트리스트 방식. 차단 시 callback(null, false) 로 헤더를 부여하지 않아 브라우저가 막게 한다 (callback(new Error(...)) 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
  • 쿠키로 인증하므로 상태를 바꾸는 요청은 출처를 검사한다 (OriginGuard, 전역 — 계약 docs/mail/api-contract.md 머리말). 비교 기준은 Host 가 아니라 허용 목록(CORS_ORIGIN)이다. 프록시 뒤에서는 Host 가 바뀌어 정상 요청까지 막힌다.
  • 클라이언트 IP 는 TRUST_PROXY(홉 수 또는 주소 목록)로만 해석한다. 모든 홉을 믿으면(true) X-Forwarded-For 위조로 IP 기준 한도(로그인 실패·rate limit)가 무력화된다. 입구는 프론트 nginx 하나로 둔다 — 프록시를 건너뛰어 백엔드에 닿는 경로가 있으면 홉 수가 어긋나 위조가 가능해진다.
  • 인터넷을 향한 컨테이너(프론트 nginx)는 백엔드에만 닿게 한다 — DB·메일 엔진과 같은 네트워크에 두지 않는다 (edge: frontend↔backend / app-network: backend↔db↔mail, docker). 메일 엔진의 신뢰 대역은 app-network 뿐이다.
  • 컨테이너가 쓰는 데이터(업로드 등)는 named volume 에 둔다 — 호스트 바인드 마운트는 root 소유로 생겨 비-root 컨테이너가 쓰지 못한다.
  • Rate Limiting: @nestjs/throttler 하나로 일원화되어 있다 (app.module.ts 의 APP_GUARD). 초과 시 429 → 전역 필터가 REQ_001 로 매핑한다.
    • 기본 정책은 루트 .env 의 THROTTLE_TTL_MS/THROTTLE_LIMIT (미설정 시 60초/100회)
    • 라우트 단위 조정: @Throttle({ default: { ttl, limit } }) / 제외: @SkipThrottle()
    • 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 더 엄격한 한도를 따로 건다
    • ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
  • 백업 비밀(13단계): restic 비밀번호 · Object Storage 키는 서버의 /etc/arasystem/(root 0600)에 두고 서버 밖(비밀번호 관리자)에도 둔다 — 잃으면 백업을 풀 수 없다. 백업에는 env 파일이 들어간다(암호화). 운영 스크립트는 비밀을 docker exec -e 이름 · 표준 입력 · 읽기 전용 마운트로만 넘기고 명령줄(ps 로 보인다)에 싣지 않는다 — Healthchecks 주소도(curl -K -)
  • 컨테이너 root 실행 금지 — 개발 환경 포함. 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
    • 프론트엔드 운영: nginxinc/nginx-unprivileged + listen 8080 (비-root 는 1024 미만 포트 바인드 불가)
    • Node 스테이지: RUN chown node:node /app → USER node → COPY --chown=node:node (소유권을 먼저 넘겨야 익명 볼륨 node_modules 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
    • 예외(13단계 — 호스트 systemd 의 백업 · 복원 보조 컨테이너): restic 은 다른 소유자(메일 엔진 UID 2000 등)의 볼륨을 읽으려고 root 로 돌되 --cap-drop ALL + DAC_READ_SEARCH · no-new-privileges 만(복원은 DAC_OVERRIDE · CHOWN · FOWNER 를 더함). 앱 컨테이너가 아니라 운영 스크립트가 잠깐 띄우는 것이다 (docker "백업 · 감시")
  • DB·Redis 는 expose 만 사용해 내부 네트워크에만 노출한다. 개발 편의로 호스트에 열어야 한다면 반드시 127.0.0.1 로 한정한다 — "${DB_PORT}:5432" 는 0.0.0.0 바인딩이라 같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.
    ports:
      - "127.0.0.1:${DB_PORT}:5432"   # ✅
      # - "${DB_PORT}:5432"           # ❌ 외부 네트워크 노출
    
  • 보안 헤더는 CSP 까지 설정한다 (frontend/nginx.conf). 외부 CDN·폰트·분석 스크립트를 추가하면 해당 도메인을 CSP 에 명시한다. script-src 에 'unsafe-inline'/'unsafe-eval' 을 넣으면 CSP 의 의미가 사라진다. API 도메인이 다르면 connect-src 에 추가해야 요청이 차단되지 않는다.

의존성

  • 의존성 추가 시 유지보수 상태를 확인하고, 주기적으로 npm audit / flutter pub outdated 로 취약점을 점검한다
  • npm audit fix 는 반드시 컨테이너 안에서 실행한다. 호스트(Windows)에서 실행하면 lock 파일이 깨진다 (nestjs)
    docker exec <프로젝트>-backend npm audit fix
    
  • --force 는 함부로 쓰지 않는다. 메이저 버전을 올려 다른 패키지와의 호환이 깨질 수 있다. 실행 전 npm audit fix --force --dry-run 으로 어떤 패키지가 올라가는지 확인한다.

남아 있는 취약점 판단 기준

모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 결정을 기록한다.

  1. 도달 가능한 코드 경로가 있는가 — 해당 기능을 쓰지 않으면 노출되지 않는다
  2. 수정이 다른 것을 깨뜨리는가 — 생태계 버전이 어긋나면 더 큰 위험
  3. 미룬다면 언제 다시 볼지 조건을 적어둔다

현재 유예 중인 항목 (frontend 2026-10-27 · backend 2026-11-05 점검):

  • frontend eslint-plugin-vue@9 → postcss-selector-parser (moderate, 선택자 파싱 CPU 과다 사용)

    • 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다.
    • 미루는 이유: 수정판이 eslint-plugin-vue@10(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다.
    • 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01.
  • backend (2026-11-05 기준 23건 — moderate 21 · low 2 · high 0, 운영 의존성만 보면 5건 — 아래 셋):

    항목 도달 경로 미루는 이유 다시 볼 시점
    js-yaml 5.3.0 (moderate, @nestjs/swagger 하위) 없음 — swagger 는 dump 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 swagger 11.4.8(2026-11-04)이 js-yaml 5.5.0 을 선언하지만 배포 2주 미만 2026-11-18 이후 npm update @nestjs/swagger(^11 범위 — lock 만, 컨테이너 안에서)
    glob 10.5.0 · minimatch 9.0.9 (moderate, typeorm 0.3.31 하위 — 11-05 새 권고) 없음 — typeorm 은 기동 때 database.config.ts 의 고정 패턴(entities · migrations)만 푼다. 외부 입력이 닿지 않고 우리 코드는 둘을 직접 쓰지 않는다 수정판이 메이저(glob 14 · minimatch 11, 2026-10-27)라 override 로 건너뛰지 않는다(아래 규칙 — 같은 메이저만) typeorm 0.3.x 가 고친 glob 을 선언한 판 + 2주, 또는 typeorm 1.x 로 옮길 때
    개발 전용 18건 — jest · @nestjs/cli · @swc/cli · typescript-eslint 아래의 glob · minimatch, deepmerge(low) 없음 — 운영 이미지에 없다 상위 스택의 갱신 판이 2주 미만이거나 메이저 그 스택을 올릴 때. ⚠️ npm audit fix 를 통째로 돌리지 않는다 — 2026-11-05 dry-run 이 @unrs/resolver-binding-* 를 1.13 으로 바꾸고 2주 미만 판을 섞었다

    2026-11-05 에 올린 것(모두 배포 2주 지난 판 — 컨테이너 안 npm install … --before=<2주 전 날짜> 로 골랐다): body-parser 2.3.0 → 2.4.0(lock 만 — express 5.2.1 의 범위 안, qs 6.16.1 · iconv-lite 0.7.3 이 따라옴), multer override ^2.4.0 → ^2.5.0, sanitize-html 2.18.0 → 2.18.1, fast-uri 3.1.8 → 3.1.9. 29건 → 23건(운영 11 → 5, high 3 → 0).

    2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다. 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 (nestjs "의존성 추가/제거").

    참고: @nestjs/platform-express 11.x 최신(11.2.8)도 multer 2.4.0 을 정확히 고정하고, 2.5.0 은 12.3.0 부터 선언한다 — multer override 는 platform-express 를 v12.3 이상으로 올릴 때 지운다.

같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성), backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등). 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다). 예외: 우리에게 닿는 취약점을 고치는 보안 릴리스는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다 (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 docker). 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 2026-11-05 에 올린 sanitize-html 2.18.1 · multer 2.5.0 · body-parser 2.4.0).

전이 의존성 취약점은 overrides 로 끊는다

직접 의존성이 취약한 하위 패키지를 정확 버전으로 고정해 두면 npm audit fix 로는 해결되지 않는다. 상위 패키지의 메이저 업그레이드를 기다리지 말고 package.json 의 overrides 로 하위 패키지만 올린다.

// backend/package.json
"overrides": {
  "multer": "^2.5.0"
}

실제 사례: @nestjs/platform-express@11 은 multer 를 2.2.0 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다. @nestjs/platform-express@12 는 multer@2.4.0 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다. v12 가 하는 일이 결국 multer 를 올리는 것이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다. 2026-11-05 multer 2.5.0(다음 DoS 수정)도 같은 길이다 — platform-express 12.3.0 이 2.5.0 을 선언하는 것이 근거, 2.4.0 과 의존성(busboy · type-is · append-field)도 같다.

규칙:

  • override 는 상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인하고 적용한다 (위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
  • 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
  • 적용 후 lint·테스트·빌드·런타임 기동까지 확인한다. override 는 npm 이 검증해 주지 않는다.
  • 상위 패키지를 정식으로 올릴 때 override 를 제거한다. 남겨두면 나중에 버전이 거꾸로 고정된다.

환경변수 관리 규칙은 env-structure, 도메인별 적용은 nestjs · flutter 참조.