InfoGrab DocsInfoGrab Docs

오프라인 인스턴스용 Package Metadata Database

요약

지속적 취약점 스캔과 CycloneDX 파일의 라이선스 스캔을 사용하려면 Package Metadata Database를 활성화해야 합니다. Package Metadata Database는 라이선스, 권고, 악성코드 권고, CVE 보강의 네 가지 데이터 세트로 구성됩니다.

지속적 취약점 스캔과 CycloneDX 파일의 라이선스 스캔을 사용하려면 Package Metadata Database를 활성화해야 합니다. 이 과정에는 통틀어 Package Metadata Database라고 부르는 라이선스 및 권고 데이터가 사용되며, 이 데이터는 EE 라이선스에 따라 사용이 허가됩니다. Package Metadata Database 사용과 관련해 다음 사항에 유의합니다.

  • GitLab은 언제든지 사전 고지 없이 단독 재량으로 Package Metadata Database의 전부 또는 일부를 변경하거나 중단할 수 있습니다.
  • Package Metadata Database에는 서드 파티 웹사이트나 리소스로 연결되는 링크가 포함될 수 있습니다. 이 링크는 편의를 위해서만 제공하며, GitLab은 해당 웹사이트나 리소스, 또는 그곳에 표시된 링크에서 제공하는 서드 파티 데이터·콘텐츠·제품·서비스에 책임을 지지 않습니다.
  • Package Metadata Database는 일부 서드 파티가 공개한 정보를 기반으로 하며, GitLab은 제공되는 콘텐츠의 정확성이나 완전성에 책임을 지지 않습니다.

Package Metadata Database는 라이선스, 권고, 악성코드 권고, CVE 보강의 네 가지 데이터 세트로 구성됩니다. 패키지 메타데이터는 v2와 v3 두 가지 형식 버전으로 게시되며, 각 데이터 세트는 둘 중 하나를 사용합니다. 두 형식 버전은 담고 있는 데이터가 다르고 다운로드 방식도 다르므로, 아래에서 각각 별도의 절차를 설명합니다. 각 데이터 세트와 GitLab 버전이 어느 형식 버전을 사용하는지는 Package Metadata Database를 참고합니다.

v2 데이터는 GitLab이 관리하고 소유하는 다음의 공개 Google Cloud Provider(GCP) 버킷에 저장됩니다.

  • License Scanning: prod-export-license-bucket-1a6c642fc4de57d4
  • Dependency scanning: prod-export-advisory-bucket-1a6c642fc4de57d4
  • CVE enrichment: prod-export-cve-enrichment-bucket-1a6c642fc4de57d4

v3 데이터에는 공개 버킷이 없습니다. 인증이 필요한 GitLab 서비스를 통해 배포되므로 다운로드하려면 라이선스 키가 필요합니다. v3 Package Metadata Database 데이터 다운로드를 참고합니다.

CVE 보강 데이터에는 취약점에 표시되는 EPSS 점수와 KEV 상태가 들어 있습니다. 라이선스 버킷과 권고 버킷만 동기화하는 인스턴스에는 의존성 스캔 데이터와 라이선스 데이터는 있지만 위험 평가 데이터는 없습니다.

Note

모든 데이터 세트에 대해 GitLab은 vendor/package_metadata 아래의 디렉터리가 있으면 데이터를 직접 다운로드하지 않고 그 디렉터리를 우선해서 읽습니다. 디렉터리가 있으면 항상 그 디렉터리가 우선하며, 온라인 동기화로 대체되지 않습니다. 따라서 오래된 데이터가 들어 있는 디렉터리는 오류 보고 없이 오래된 데이터를 제공합니다. 악성코드 권고도 같은 규칙을 따릅니다.

첫 로드 전 준비#

무엇이든 다운로드하기 전에 동기화할 패키지 레지스트리 유형을 정합니다. 관리자 설정에서 활성화한 유형만 가져옵니다. 비활성화한 유형을 다운로드하면 시간과 디스크 공간만 소모되고, 그 데이터는 데이터베이스에 반영되지 않습니다.

가져오기 속도는 인스턴스의 리소스가 아니라 동기화 작업의 스로틀이 정하므로, 더 큰 인스턴스라고 해서 더 빠르게 가져오지는 않습니다. 또한 각 실행은 정해진 시간이 지나면 중단되고, 다음 실행에서 체크포인트부터 다시 시작합니다. 모든 레지스트리 유형을 활성화한 상태의 첫 로드에는 오랜 시간이 걸립니다. 시간을 줄이려면 활성화한 유형을 좁힙니다.

gsutil로 v2 패키지 메타데이터 다운로드#

이 절에서는 v2 데이터를 다운로드하는 방법을 설명합니다.

  1. gsutil 도구를 설치합니다.

  2. GitLab Rails 디렉터리의 루트를 찾습니다.

    export GITLAB_RAILS_ROOT_DIR="$(gitlab-rails runner 'puts Rails.root.to_s')"
    echo $GITLAB_RAILS_ROOT_DIR
    
  3. 동기화할 데이터 유형을 설정합니다.

    # For License Scanning
    export PKG_METADATA_BUCKET=prod-export-license-bucket-1a6c642fc4de57d4
    export DATA_DIR="licenses"
    
    # For dependency scanning
    export PKG_METADATA_BUCKET=prod-export-advisory-bucket-1a6c642fc4de57d4
    export DATA_DIR="advisories"
    
    # For CVE enrichment
    export PKG_METADATA_BUCKET=prod-export-cve-enrichment-bucket-1a6c642fc4de57d4
    export DATA_DIR="cve_enrichment"
    
  4. 패키지 메타데이터 내보내기 파일을 다운로드합니다.

    # To download the package metadata exports, an outbound connection to Google Cloud Storage bucket must be allowed.
    mkdir -p "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
    gsutil -m rsync -r -d gs://$PKG_METADATA_BUCKET "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
    
    # Alternatively, if the GitLab instance is not allowed to connect to the Google Cloud Storage bucket, the package metadata
    # exports can be downloaded using a machine with the allowed access, and then copied to the root of the GitLab Rails directory.
    rsync rsync://example_username@gitlab.example.com/package_metadata/$DATA_DIR "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
    
  5. GitLab 19.4 이전 버전에서 CVE 보강 데이터를 사용하는 경우, 다운로드한 파일을 한 디렉터리 수준 위로 옮깁니다. GitLab 19.5 이후에서는 이 단계를 건너뜁니다. 자세한 내용은 머지 리퀘스트 253401을 참고합니다.

    라이선스 버킷과 권고 버킷에는 v2/ 아래에 패키지 레지스트리마다 디렉터리가 하나씩 있으며, GitLab은 이를 복사된 그대로 읽습니다. CVE 보강 데이터에는 패키지 레지스트리가 없으며, 그 버킷에는 해당 위치에 cve_enrichment 디렉터리 하나만 있습니다. GitLab 19.4 이전 버전에서 GitLab은 CVE 보강 데이터를 cve_enrichment/v2/<sequence>/<chunk>.ndjson 경로에서만, 정확히 그 깊이에서만 읽습니다. 그대로 복사하면 파일이 한 디렉터리 수준 더 깊은 곳에 놓입니다. 이 경우 GitLab은 아무것도 가져오지 않으며 로그도 남기지 않습니다.

    cd "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2"
    cp -a cve_enrichment/. . && rm -r cve_enrichment
    

    CVE 보강 내보내기 파일을 다운로드할 때마다 이 단계를 반복합니다. cp -a는 이미 있는 시퀀스 디렉터리에 새 파일을 병합하지만, mv는 그 경우 중단됩니다.

Google Cloud Storage REST API로 v2 패키지 메타데이터 다운로드#

이 절에서는 v2 패키지 메타데이터를 다운로드하는 다른 방법을 설명합니다.

패키지 메타데이터 내보내기 파일은 Google Cloud Storage API로도 다운로드할 수 있습니다. 내용은 https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o와 https://storage.googleapis.com/storage/v1/b/prod-export-advisory-bucket-1a6c642fc4de57d4/o에서 확인할 수 있습니다. 다음은 cURL과 jq를 사용해 다운로드하는 예시입니다.

#!/bin/bash

set -euo pipefail

DATA_TYPE=$1

GITLAB_RAILS_ROOT_DIR="$(gitlab-rails runner 'puts Rails.root.to_s')"

if [ "$DATA_TYPE" == "license" ]; then
  PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses"
elif [ "$DATA_TYPE" == "advisory" ]; then
  PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories"
else
  echo "Usage: import_script.sh [license|advisory]"
  exit 1
fi

PKG_METADATA_BUCKET="prod-export-$DATA_TYPE-bucket-1a6c642fc4de57d4"
PKG_METADATA_DOWNLOADS_OUTPUT_FILE="/tmp/package_metadata_${DATA_TYPE}_object_links.tsv"

# Download the contents of the bucket
# The script downloads all the objects and creates files with a maximum 1000 objects per file in JSON format.

MAX_RESULTS=1000
TEMP_FILE="out.json"

curl --silent --show-error --request GET "https://storage.googleapis.com/storage/v1/b/$PKG_METADATA_BUCKET/o?maxResults=$MAX_RESULTS" >"$TEMP_FILE"
NEXT_PAGE_TOKEN="$(jq -r '.nextPageToken' $TEMP_FILE)"
jq -r '.items[] | [.name, .mediaLink] | @tsv' "$TEMP_FILE" >"$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"

while [ "$NEXT_PAGE_TOKEN" != "null" ]; do
  curl --silent --show-error --request GET "https://storage.googleapis.com/storage/v1/b/$PKG_METADATA_BUCKET/o?maxResults=$MAX_RESULTS&pageToken=$NEXT_PAGE_TOKEN" >"$TEMP_FILE"
  NEXT_PAGE_TOKEN="$(jq -r '.nextPageToken' $TEMP_FILE)"
  jq -r '.items[] | [.name, .mediaLink] | @tsv' "$TEMP_FILE" >>"$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"
  #use for API rate-limiting
  sleep 1
done

trap 'rm -f "$TEMP_FILE"' EXIT

echo "Fetched $DATA_TYPE export manifest"

# Parse the links and names for the bucket objects and output them into a tsv file

echo -e "Saving package metadata exports to $PKG_METADATA_DIR\n"

# Track how many objects will be downloaded
INDEX=1
TOTAL_OBJECT_COUNT="$(wc -l "$PKG_METADATA_DOWNLOADS_OUTPUT_FILE" | awk '{print $1}')"

# Download the objects
while IFS= read -r line; do
  FILE="$(echo -n "$line" | awk '{print $1}')"
  URL="$(echo -n "$line" | awk '{print $2}')"
  OUTPUT_PATH="$PKG_METADATA_DIR/$FILE"

  echo "Downloading $FILE"

  if [ ! -f "$OUTPUT_PATH" ]; then
    curl --progress-bar --create-dirs --output "$OUTPUT_PATH" --request "GET" "$URL"
  else
    echo "Existing file found"
  fi

  echo -e "$INDEX of $TOTAL_OBJECT_COUNT objects downloaded\n"

  INDEX=$((INDEX + 1))
done <"$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"

echo "All objects saved to $PKG_METADATA_DIR"

일정에 따라 v2 패키지 메타데이터 다운로드#

GitLab 인스턴스는 package_metadata 디렉터리의 내용과 정기적으로 동기화됩니다. 업스트림 변경 사항으로 로컬 사본을 자동으로 업데이트하려면 cron 작업을 추가해 새 내보내기 파일을 주기적으로 다운로드할 수 있습니다. 예를 들어 다음 crontab을 추가하면 30분마다 실행되는 cron 작업을 설정할 수 있습니다.

라이선스 작업과 권고 작업은 -y "^v3\/"를 전달해 v3/ 폴더를 제외하므로 v2 형식 버전만 미러링합니다.

v2 라이선스 스캔의 경우(GitLab 19.3 이전 버전):

*/30 * * * * gsutil -m rsync -r -d -y "^v3\/" gs://prod-export-license-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses

v2 의존성 스캔의 경우:

*/30 * * * * gsutil -m rsync -r -d -y "^v3\/" gs://prod-export-advisory-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories

GitLab 19.4 이전 버전의 CVE 보강 데이터는 -d를 생략하고, 다운로드 절차의 이동 단계를 이어 붙입니다.

*/30 * * * * gsutil -m rsync -r gs://prod-export-cve-enrichment-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment && cd $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2 && cp -a cve_enrichment/. . && rm -r cve_enrichment

이동 단계를 거치면 로컬 구조가 버킷과 달라지므로 -d를 쓰면 이동한 파일이 삭제됩니다. 또한 이동 단계는 gsutil rsync가 비교 대상으로 삼는 디렉터리도 제거하므로, -d의 유무와 관계없이 매 실행마다 내보내기 파일 전체를 다시 다운로드합니다.

GitLab 19.5 이후에서는 이동 단계 없이 다운로드를 실행합니다. 이전 버전에서 업그레이드하는 경우 기존 CVE 보강 줄을 다음으로 바꿉니다.

*/30 * * * * gsutil -m rsync -r -d gs://prod-export-cve-enrichment-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment
Warning

라이선스 및 권고 예시와 GitLab 19.5 이후의 CVE 보강 예시에 있는 -d 플래그는 버킷에 더 이상 없는 로컬 파일을 삭제하며, 이로 인해 진행 중인 가져오기가 사라질 수 있습니다. GitLab은 데이터 유형과 패키지 레지스트리마다 마지막으로 가져온 파일을 기록하고, 그 다음 파일부터 다시 시작합니다. 정리 작업으로 그 기록이 가리키는 시퀀스 디렉터리가 삭제되면 GitLab은 해당 기록을 찾지 못합니다. 그러면 해당 데이터 유형 전체를 처음부터 다시 가져옵니다. 정리하기 전에 각 데이터 유형의 기록된 시퀀스가 디스크에 아직 있는지 확인합니다. 기록된 시퀀스를 읽는 방법은 데이터 확인을 참고합니다. 둘 이상의 인스턴스가 읽는 디렉터리에서는 인스턴스 중 가장 오래된 기록 시퀀스보다 앞의 항목만 정리합니다.

이 동작은 GitLab이 파일을 한 번에 하나씩 가져오는 라이선스, 권고, CVE 보강 내보내기 파일에 적용됩니다. 악성코드 권고 절차는 다릅니다. 이 절차는 매 실행마다 전체 스냅샷을 교체하며, 여기서는 rsync --delete가 필수입니다.

v3 Package Metadata Database 데이터 다운로드#

v3 라이선스와 악성코드 권고는 공개 버킷에 없습니다. 이 데이터는 인증이 필요한 GitLab 서비스인 Package Metadata Database 배포 서비스(PDS)에서 제공하므로 오프라인 인스턴스가 직접 다운로드할 수 없습니다. 대신 인터넷에 연결된 머신에서 다음 스크립트를 실행한 다음, 그 데이터를 오프라인 인스턴스로 복사합니다. 이 스크립트는 두 데이터 세트 모두에 사용할 수 있습니다. 첫 번째 인수로 데이터 세트 이름을 전달합니다.

스크립트는 각 패키지 레지스트리에 대해, 디스크에 이미 있는 스냅샷 이후에 게시된 델타를 다운로드합니다. 스냅샷이 없는 경우, 그리고 레지스트리가 델타 방식보다 새 스냅샷이 더 효율적일 만큼 많이 뒤처진 경우에는 새 스냅샷을 다운로드합니다. 레지스트리의 체크포인트는 해당 레지스트리의 아카이브를 모두 다운로드한 뒤 마지막에 기록하므로, 일부만 다운로드된 상태가 완료된 것으로 오인되는 일은 없습니다. 스크립트는 라이선스 키를 3일간 유효한 토큰으로 교환합니다. 인터넷에 연결된 머신에는 라이선스 파일의 사본이 필요하지만, 오프라인 인스턴스에 접근할 필요는 없습니다.

사전 요구 사항:

  • 관리자 액세스 권한.
  • 오프라인 라이선스. 레거시 라이선스 파일은 작동하지 않습니다. 토큰 요청이 Invalid cloud license로 실패하고 스크립트가 중단됩니다. 라이선스 유형은 Admin 영역 대시보드의 License overview에서 확인할 수 있습니다.
  • 인터넷에 연결된 머신에는 cURL, jq 1.6 이상, 그리고 customers.gitlab.com, pmdb-dist-svc.runway.gitlab.net, storage.googleapis.com에 대한 아웃바운드 액세스가 필요합니다. 배포 서비스는 gitlab.net 도메인에 있으므로 gitlab.com으로만 제한한 허용 목록으로는 접근할 수 없습니다.
  • 오프라인 인스턴스에는 rsync.

오프라인 라이선스를 받으려면 승인이 필요한 클라우드 라이선스 옵트아웃 면제를 받아야 합니다. 자세한 내용은 GitLab 영업 담당자에게 문의합니다. 이미 오프라인 라이선스가 있다면 고객 포털에서 파일을 다시 다운로드할 수 있습니다.

스크립트에는 라이선스 파일에 들어 있는 긴 텍스트 블록인 라이선스 키가 필요합니다. 이를 파일로 저장하려면 오프라인 인스턴스에서 다음을 실행합니다.

sudo gitlab-rails runner 'puts License.current.data' > Gitlab.gitlab-license

활성화 코드는 라이선스 키가 아닙니다. 활성화 코드는 짧은 문자열이며 토큰 요청에서 거부됩니다.

이 단계는 Linux 패키지 설치에 적용됩니다. Kubernetes 설치에서는 데이터가 Sidekiq 파드가 읽는 볼륨에 있어야 합니다. Helm 차트로 설치한 인스턴스를 참고합니다.

Warning

Docker 설치에서는 Rails 디렉터리가 마운트된 볼륨에 있지 않습니다. 데이터를 복사하기 전에 vendor/package_metadata를 바인드 마운트합니다. 그렇지 않으면 컨테이너를 업그레이드할 때 데이터가 사라집니다.

다음 스크립트를 download_pmdb_data.sh로 저장하고 chmod +x download_pmdb_data.sh로 실행 권한을 부여합니다.

#!/bin/bash

set -euo pipefail

CDOT_URL="${CDOT_URL:-https://customers.gitlab.com}"
PDS_URL="${PDS_URL:-https://pmdb-dist-svc.runway.gitlab.net}"

# A registry far enough behind is cheaper to replace with a fresh snapshot than
# to catch up one delta at a time. Set to 0 to never download a delta.
MAX_DELTAS="${MAX_DELTAS:-100}"

if [ $# -lt 5 ]; then
  echo "Usage: download_pmdb_data.sh <license_file> <gitlab_version> <dataset> <output_dir> <registry>"
  echo "dataset is licenses or malware_advisories."
  echo "Pass the package registries to download separated by space, or 'all' for every supported registry."
  exit 1
fi

LICENSE_FILE=$1
GITLAB_VERSION=$2
DATASET=$3
OUTPUT_DIR=$4
shift 4
REQUESTED_REGISTRIES="$*"

# The two datasets differ only in their distribution service path and the name
# used in the closing message. Everything after this point is common to both.
case "$DATASET" in
licenses)
  DATASET_PATH="licenses"
  DATASET_LABEL="License data"
  ;;
malware_advisories)
  DATASET_PATH="malware/advisories"
  DATASET_LABEL="Advisories"
  ;;
*)
  echo "dataset must be licenses or malware_advisories"
  exit 1
  ;;
esac

if [ -z "$OUTPUT_DIR" ]; then
  echo "output_dir must not be empty"
  exit 1
fi

if [ ! -r "$LICENSE_FILE" ]; then
  echo "Cannot read $LICENSE_FILE"
  exit 1
fi

mkdir -p "$OUTPUT_DIR"

# Serialize runs against this output directory. Two concurrent runs would race
# on the same dataset directory: one can write checkpoint.json while the other
# has deleted and re-created the directory, leaving a partial shard set that
# GitLab imports as a complete snapshot. mkdir either creates the directory or
# fails, in one step, so it needs nothing beyond the shell.
LOCK_DIR="$OUTPUT_DIR/.download.lock"

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  echo "Another download is already running for $OUTPUT_DIR"
  echo "If no other run is active, remove $LOCK_DIR and try again."
  exit 1
fi

REQUEST_FILE="$(mktemp)"
RESPONSE_FILE="$(mktemp)"
SNAPSHOT_FILE="$(mktemp)"
SHARDS_FILE="$(mktemp)"
DELTAS_FILE="$(mktemp)"
HEADER_FILE="$(mktemp)"
trap 'rm -f "$REQUEST_FILE" "$RESPONSE_FILE" "$SNAPSHOT_FILE" "$SHARDS_FILE" "$DELTAS_FILE" "$HEADER_FILE"; rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
chmod 600 "$HEADER_FILE"

# Exchange the license key for a Cloud Connector token.
GRAPHQL_QUERY='query($licenseKey: String!, $gitlabVersion: String!) {
  cloudConnectorAccess(licenseKey: $licenseKey, gitlabVersion: $gitlabVersion) {
    serviceToken { token }
  }
}'

jq --null-input --arg query "$GRAPHQL_QUERY" --rawfile licenseKey "$LICENSE_FILE" \
  --arg gitlabVersion "$GITLAB_VERSION" \
  '{query: $query, variables: {licenseKey: $licenseKey, gitlabVersion: $gitlabVersion}}' >"$REQUEST_FILE"

HTTP_STATUS="$(curl --silent --show-error --request POST "$CDOT_URL/graphql" \
  --header 'Content-Type: application/json' --data @"$REQUEST_FILE" \
  --output "$RESPONSE_FILE" --write-out '%{http_code}')"

if [ "$HTTP_STATUS" != "200" ]; then
  echo "Token request to $CDOT_URL failed with HTTP $HTTP_STATUS"
  head -c 500 "$RESPONSE_FILE"
  exit 1
fi

TOKEN="$(jq --raw-output '.data.cloudConnectorAccess.serviceToken.token // empty' "$RESPONSE_FILE")"

if [ -z "$TOKEN" ]; then
  echo "$CDOT_URL refused to issue a token for $LICENSE_FILE"
  echo "It must hold the license key, the output of: gitlab-rails runner 'puts License.current.data'"
  echo "An activation code is not a license key, and a legacy license is not accepted."
  echo "The customer portal reported:"
  jq --raw-output '.errors[]?.message // "no reason given"' "$RESPONSE_FILE"
  exit 1
fi

# The distribution service requires X-Gitlab-Instance-Id to equal the token's
# subject claim and X-Gitlab-Realm to equal its realm claim. Both are in the
# payload, which is the second dot-separated segment of the token.
CLAIMS="$(printf '%s' "$TOKEN" |
  jq --raw-input 'split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson')"

INSTANCE_ID="$(jq --raw-output '.sub // empty' <<<"$CLAIMS")"
REALM="$(jq --raw-output '.gitlab_realm // empty' <<<"$CLAIMS")"

if [ -z "$INSTANCE_ID" ] || [ -z "$REALM" ]; then
  echo "The token is missing its subject or realm claim"
  exit 1
fi

# Pass the headers through a file so the token never appears in a process
# list. It is readable there by any local user for the download's duration.
{
  printf 'header = "Authorization: Bearer %s"\n' "$TOKEN"
  printf 'header = "X-Gitlab-Instance-Id: %s"\n' "$INSTANCE_ID"
  printf 'header = "X-Gitlab-Realm: %s"\n' "$REALM"
} >"$HEADER_FILE"

# Writes the response body to $2 and returns the HTTP status.
pds_request() {
  curl --silent --show-error --config "$HEADER_FILE" \
    --output "$2" --write-out '%{http_code}' "$1"
}

HTTP_STATUS="$(pds_request "$PDS_URL/v1/$DATASET_PATH/supported" "$RESPONSE_FILE")"

if [ "$HTTP_STATUS" != "200" ]; then
  echo "Request for the supported package registries failed with HTTP $HTTP_STATUS"
  head -c 500 "$RESPONSE_FILE"
  exit 1
fi

SUPPORTED="$(jq --raw-output '.registries[]' "$RESPONSE_FILE")"
SKIPPED=""

if [ -z "$SUPPORTED" ]; then
  echo "No package registries are available"
  exit 1
fi

echo "Available package registries: $(echo "$SUPPORTED" | tr '\n' ' ')"

# Disable filename expansion so a registry name is never treated as a glob.
set -f

if [ "$REQUESTED_REGISTRIES" = "all" ]; then
  REGISTRIES="$SUPPORTED"
else
  REGISTRIES="$REQUESTED_REGISTRIES"

  for REGISTRY in $REGISTRIES; do
    if ! grep --quiet --fixed-strings --line-regexp "$REGISTRY" <<<"$SUPPORTED"; then
      echo "$REGISTRY is not a supported package registry"
      exit 1
    fi
  done
fi

# Downloads the current snapshot into a registry's full_dataset directory, and
# removes any delta archives beside it: a snapshot supersedes every delta older
# than itself, and GitLab reads whatever is newer than its own checkpoint, so
# leaving them would re-apply changes the snapshot already contains.
download_snapshot() {
  REGISTRY=$1
  UNTIL=$2
  DATASET_DIR="$OUTPUT_DIR/v3/$REGISTRY/full_dataset"

  # Remove any previous snapshot. GitLab reads every archive in this directory,
  # so archives left over from an earlier snapshot would be imported alongside
  # the new ones, restoring data that changed since.
  rm -rf "$DATASET_DIR" "$OUTPUT_DIR/v3/$REGISTRY/deltas"
  mkdir -p "$DATASET_DIR"

  # Read the shards from the saved /all body. The delta loop reuses
  # $RESPONSE_FILE, so by the time a fallback lands here it no longer holds it.
  jq --raw-output '.shards[] | [.shard, (.signed_url // .url)] | @tsv' "$SNAPSHOT_FILE" >"$SHARDS_FILE"
  SHARD_COUNT="$(wc -l <"$SHARDS_FILE" | tr -d ' ')"

  while IFS=$'\t' read -r SHARD URL; do
    echo "Downloading $REGISTRY snapshot archive $SHARD"
    curl --fail --silent --show-error --location --output "$DATASET_DIR/$SHARD.tar.zst.part" "$URL"
    mv "$DATASET_DIR/$SHARD.tar.zst.part" "$DATASET_DIR/$SHARD.tar.zst"
  done <"$SHARDS_FILE"

  # Write the checkpoint last. GitLab ignores a directory that has no
  # checkpoint, so an interrupted download is never imported as a snapshot.
  jq --null-input --argjson until "$UNTIL" --argjson shards "$SHARD_COUNT" \
    '{until: $until, shards: $shards}' >"$DATASET_DIR/checkpoint.json"
}

for REGISTRY in $REGISTRIES; do
  HTTP_STATUS="$(pds_request "$PDS_URL/v1/$DATASET_PATH/all?purl_type=$REGISTRY" "$RESPONSE_FILE")"

  # A pending snapshot is the only skippable outcome. Every other 503 is
  # transient, and treating it as "no data" would delete a registry that the
  # instance already has.
  if [ "$HTTP_STATUS" = "503" ]; then
    # A 503 from in front of the service has no JSON body, so keep the reason
    # empty rather than letting jq abort the script.
    REASON="$(jq --raw-output '.reason // empty' "$RESPONSE_FILE" 2>/dev/null || true)"

    if [ "$REASON" = "snapshot_not_yet_published" ]; then
      echo "Skipping $REGISTRY, no snapshot is published yet"
      SKIPPED="$SKIPPED $REGISTRY"
      continue
    fi

    echo "Request for $REGISTRY failed with HTTP 503, try again later ($REASON)"
    exit 1
  fi

  if [ "$HTTP_STATUS" != "200" ]; then
    echo "Request for $REGISTRY failed with HTTP $HTTP_STATUS"
    head -c 500 "$RESPONSE_FILE"
    exit 1
  fi

  # Keep the /all body: the snapshot is the fallback for every branch below,
  # and the delta loop writes over $RESPONSE_FILE before one can be taken.
  cp "$RESPONSE_FILE" "$SNAPSHOT_FILE"

  UNTIL="$(jq --raw-output '.until // empty' "$SNAPSHOT_FILE")"

  if [ -z "$UNTIL" ]; then
    echo "The response for $REGISTRY has no snapshot timestamp"
    exit 1
  fi

  REGISTRY_DIR="$OUTPUT_DIR/v3/$REGISTRY"
  LOCAL_UNTIL="$(jq --raw-output '.until // empty' \
    "$REGISTRY_DIR/full_dataset/checkpoint.json" 2>/dev/null || true)"

  # Take the whole snapshot unless the output directory already holds one this
  # run can continue from. A delta cannot bootstrap a registry, and `since` has
  # to be a 10-digit unix-seconds value or the service answers 400, so a
  # checkpoint that is missing, unreadable or in another unit starts over.
  if ! [[ "$LOCAL_UNTIL" =~ ^[0-9]{10}$ ]]; then
    download_snapshot "$REGISTRY" "$UNTIL"
    continue
  fi

  # Resume from the newest archive on disk, snapshot or delta, which is where
  # GitLab's own checkpoint ends up once it has imported them. Asking from an
  # older timestamp would download deltas that are already here. Names that are
  # not a bare timestamp are ignored, matching what the connector reads.
  SINCE="$(find "$REGISTRY_DIR/deltas" -type f -name '*.tar.zst' 2>/dev/null |
    sed -e 's#.*/##' -e 's#\.tar\.zst$##' | grep -E '^[0-9]{10}$' |
    sort -n | tail -1 || true)"

  if [ -z "$SINCE" ] || [ "$LOCAL_UNTIL" -gt "$SINCE" ]; then
    SINCE="$LOCAL_UNTIL"
  fi

  # With deltas disabled, the published snapshot decides everything.
  if [ "$MAX_DELTAS" -le 0 ]; then
    if [ "$UNTIL" -gt "$LOCAL_UNTIL" ]; then
      echo "Replacing $REGISTRY, the published snapshot moved to $UNTIL"
      download_snapshot "$REGISTRY" "$UNTIL"
    else
      echo "Skipping $REGISTRY, snapshot $LOCAL_UNTIL is already downloaded"
    fi
    continue
  fi

  # Page forward. The service caps a response at 20 archives per registry and
  # sends the oldest of the backlog first, so re-asking from the newest archive
  # received walks the whole backlog without gaps.
  : >"$DELTAS_FILE"
  DELTA_TOTAL=0
  CAPPED=""

  while :; do
    HTTP_STATUS="$(pds_request "$PDS_URL/v1/$DATASET_PATH/delta?since=$REGISTRY:$SINCE" "$RESPONSE_FILE")"

    if [ "$HTTP_STATUS" != "200" ]; then
      echo "Delta request for $REGISTRY failed with HTTP $HTTP_STATUS"
      head -c 500 "$RESPONSE_FILE"
      exit 1
    fi

    # Registries are keyed by the identifier /supported gave us, and one that is
    # up to date comes back as an empty array rather than being left out.
    PAGE_COUNT="$(jq --arg r "$REGISTRY" '.purl_types[$r] // [] | length' "$RESPONSE_FILE")"

    if [ "$PAGE_COUNT" -eq 0 ]; then
      break
    fi

    jq --raw-output --arg r "$REGISTRY" \
      '.purl_types[$r] | sort_by(.delta | tonumber) | .[] | [.delta, (.signed_url // .url)] | @tsv' \
      "$RESPONSE_FILE" >>"$DELTAS_FILE"

    DELTA_TOTAL=$((DELTA_TOTAL + PAGE_COUNT))
    SINCE="$(cut -f1 "$DELTAS_FILE" | tail -1)"

    # Past the ceiling, one snapshot is fewer bytes than the rest of the backlog.
    if [ "$DELTA_TOTAL" -gt "$MAX_DELTAS" ]; then
      CAPPED=yes
      break
    fi
  done

  if [ -n "$CAPPED" ]; then
    echo "Replacing $REGISTRY, it is more than $MAX_DELTAS deltas behind"
    download_snapshot "$REGISTRY" "$UNTIL"
    continue
  fi

  if [ "$DELTA_TOTAL" -eq 0 ]; then
    echo "Skipping $REGISTRY, it is up to date at $SINCE"
    continue
  fi

  # Deltas sit beside the snapshot rather than replacing it: GitLab reads the
  # snapshot on a first sync and the deltas on every sync after it.
  mkdir -p "$REGISTRY_DIR/deltas"

  while IFS=$'\t' read -r DELTA URL; do
    echo "Downloading $REGISTRY delta $DELTA"
    curl --fail --silent --show-error --location \
      --output "$REGISTRY_DIR/deltas/$DELTA.tar.zst.part" "$URL"
    mv "$REGISTRY_DIR/deltas/$DELTA.tar.zst.part" "$REGISTRY_DIR/deltas/$DELTA.tar.zst"
  done <"$DELTAS_FILE"

  echo "Downloaded $DELTA_TOTAL delta(s) for $REGISTRY"
done

set +f

if [ -n "$SKIPPED" ]; then
  echo "Warning: no snapshot is published yet for:$SKIPPED"
  echo "The downloaded registries are complete and safe to copy. Run the script again later to pick up the rest."
fi

echo "$DATASET_LABEL saved to $OUTPUT_DIR"

스크립트는 다음과 같이 호출합니다.

./download_pmdb_data.sh <license_file> <gitlab_version> <dataset> <output_dir> <registry>

다음 표는 스크립트의 각 인수가 의미하는 바를 설명합니다.

인수 설명
license_file 인스턴스의 라이선스 키가 들어 있는 파일의 경로입니다. 라이선스 키는 sudo gitlab-rails runner 'puts License.current.data'의 출력입니다. 활성화 코드는 라이선스 키가 아닙니다.
gitlab_version 데이터를 가져오는 GitLab 인스턴스의 버전이며, sudo gitlab-rails runner 'puts Gitlab::VERSION'의 출력입니다. 예: 19.4.0-ee.
dataset licenses 또는 malware_advisories.
output_dir 기록할 디렉터리입니다. 없으면 생성합니다. 변경되지 않은 스냅샷을 건너뛸 수 있도록 매 실행마다 같은 디렉터리를 사용합니다.
registry 하나 이상의 패키지 레지스트리입니다. 예: npm, pypi. 지원되는 모든 레지스트리는 all로 지정합니다. 관리자 설정에서 활성화한 유형의 레지스트리를 전달합니다. 레지스트리가 여러 개이면 공백으로 구분합니다. 스크립트는 다운로드하기 전에 지원되는 목록을 출력합니다.

스크립트는 패키지 유형이 아니라 레지스트리 식별자를 이름으로 하는 디렉터리를 패키지 레지스트리마다 하나씩 생성합니다. 예를 들면 다음과 같습니다.

licenses/
└── v3/
    └── npm/
        ├── full_dataset/
        │   ├── 00.tar.zst
        │   ├── 01.tar.zst
        │   ├── ...
        │   ├── 7f.tar.zst
        │   └── checkpoint.json
        └── deltas/
            ├── 1757520000.tar.zst
            └── 1757606400.tar.zst

각 스냅샷 아카이브의 이름은 16진수 샤드 식별자입니다. 레지스트리별 아카이브 수는 서비스가 정하며, 데이터가 없는 아카이브가 있을 수 있습니다. 각 델타 아카이브의 이름은 해당 기간이 끝나는 Unix 타임스탬프입니다. GitLab이 이 폴더에서 무엇을 읽는지는 v3 라이선스 데이터 형식 버전을 참고합니다.

레지스트리의 첫 실행은 스냅샷 전체를 다운로드하고, 이후 실행은 출력 디렉터리에 이미 있는 가장 최신 아카이브 이후에 서비스가 게시한 항목만 요청합니다.

Note

매 실행마다 같은 출력 디렉터리를 재사용합니다. 그렇지 않으면 스크립트가 이어서 받을 기준이 없어 스냅샷 전체를 다시 다운로드합니다.

레지스트리가 MAX_DELTAS개보다 많은 아카이브만큼 뒤처지면 스크립트는 스냅샷을 교체하고 그 옆의 deltas/ 폴더를 제거합니다. 서비스가 자체 일정에 따라 스냅샷을 다시 만들기 때문에 스냅샷이 그 옆의 델타보다 새로운 것은 정상이며, 스크립트는 그 스냅샷을 다시 다운로드하지 않고 계속 델타를 읽습니다. MAX_DELTAS의 기본값은 100이고, 서비스는 요청당 최대 20개의 델타 아카이브를 제공합니다. 0으로 설정하면 델타를 전혀 다운로드하지 않으며, 매 실행마다 스냅샷을 교체하던 이전 동작으로 돌아갑니다.

스크립트는 출력 디렉터리에 락도 생성하므로, 첫 번째 실행이 데이터를 기록하는 중에 다시 실행하면 디렉터리를 손상시키지 않고 종료합니다. 출력 디렉터리에 이미 있는 것보다 새로운 항목이 없으면 스크립트는 해당 레지스트리를 건너뛰므로, 일정에 따라 실행해도 안전합니다.

각 데이터 세트는 해당 절의 단계에 따라 제자리로 복사합니다. 라이선스 데이터와 악성코드 권고를 참고합니다.

GitLab v3 악성코드 권고 다운로드#

히스토리
  • GitLab 19.3에서 sync_malware_advisories 및 ingest_malware_advisories라는 플래그와 함께 도입되었습니다. 기본적으로 활성화되어 있습니다.
  • GitLab 19.5에서 정식 출시되었습니다. 기능 플래그 sync_malware_advisories와 ingest_malware_advisories가 제거되었습니다.
Note

이 기능은 Ultimate 외에도 의존성 방화벽 애드온을 사용하는 Premium 고객이 사용할 수 있습니다.

GitLab 악성코드 권고는 패키지 레지스트리에서 발견된 알려진 악성 패키지를 다룹니다. 이 권고는 Package Metadata Database 배포 서비스에서 제공하므로, 인터넷에 연결된 머신에서 다운로드해 오프라인 인스턴스로 복사합니다. v3 Package Metadata Database 데이터 다운로드에 설명된 공용 스크립트와 사전 요구 사항을 사용해 다운로드합니다.

./download_pmdb_data.sh ./Gitlab.gitlab-license 19.3.0-ee malware_advisories ./malware_advisories npm pypi

권고를 오프라인 인스턴스로 복사하려면 다음과 같이 합니다.

  1. 출력 디렉터리를 오프라인 인스턴스로 전송합니다.

  2. 오프라인 인스턴스에서 GitLab Rails 디렉터리의 루트를 찾은 다음, 권고를 제자리로 복사하고 권한을 업데이트합니다.

    export GITLAB_RAILS_ROOT_DIR="$(sudo gitlab-rails runner 'puts Rails.root.to_s')"
    echo $GITLAB_RAILS_ROOT_DIR
    sudo mkdir -p "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/malware_advisories"
    sudo rsync --recursive --delete ./malware_advisories/ "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/malware_advisories/"
    sudo chmod -R 755 "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/"
    

PackageMetadata::MalwareAdvisoriesSyncWorker cron 작업은 5분마다 실행되며 다음 실행에서 권고를 가져옵니다. GitLab Self-Managed에서는 인스턴스 간 부하를 분산하기 위해 이 작업에 최대 5분의 오프셋이 추가되므로, 파일을 복사한 뒤 최대 10분 후에 가져오기가 시작될 수 있습니다. 관리자 설정에서 활성화한 패키지 레지스트리 유형만 가져오므로, 인스턴스에는 가져오지 않는 레지스트리의 데이터가 있을 수 있습니다.

권고를 업데이트하려면 이 절차를 반복합니다. 이전 스냅샷의 아카이브가 새 아카이브와 함께 가져와지지 않도록 rsync --delete가 필요합니다. 매 실행마다 같은 출력 디렉터리를 재사용합니다. rsync --delete는 출력 디렉터리에 없는 레지스트리를 모두 제거하며, 스냅샷이 아직 게시되지 않아 건너뛴 레지스트리도 여기에 포함됩니다.

v3 라이선스 데이터 다운로드#

히스토리
  • GitLab 19.4에서 sync_v3_license_expressions라는 플래그와 함께 도입되었습니다. 기본적으로 활성화되어 있습니다.
Feature flag

이 기능의 사용 가능 여부는 기능 플래그로 제어됩니다. 자세한 내용은 히스토리를 참고합니다.

플래그를 끄면 인스턴스가 v2 레이아웃으로 되돌아가며 v3 체크포인트를 삭제합니다. 플래그를 다시 켜면 인스턴스는 마지막으로 동기화한 스냅샷부터 이어서 가져오지 않고 v3 데이터를 처음부터 다시 가져옵니다.

v3 라이선스 데이터 레이아웃은 단일 라이선스 식별자 대신 Software Package Data Exchange(SPDX) 라이선스 표현식을 담습니다. 예: MIT OR Apache-2.0.

Package Metadata Database 배포 서비스가 v3 라이선스 데이터를 배포하므로, 인터넷에 연결된 머신에서 다운로드해 오프라인 인스턴스로 복사합니다. 라이선스 버킷에 있는 모든 형식을 받지 않고, 활성화한 레지스트리에 필요한 데이터만 다운로드합니다. gsutil은 필요하지 않습니다. v3 Package Metadata Database 데이터 다운로드에 설명된 공용 스크립트와 사전 요구 사항을 사용해 다운로드합니다.

./download_pmdb_data.sh ./Gitlab.gitlab-license 19.4.0-ee licenses ./licenses npm pypi

vendor/package_metadata/licenses 아래에 v3 디렉터리가 있으면 인스턴스는 v2 디렉터리 대신 그 디렉터리에서 라이선스를 동기화합니다. 동기화 작업이 실행되기 전에 아래의 복사 단계를 완료합니다.

라이선스 데이터를 오프라인 인스턴스로 복사하려면 다음과 같이 합니다.

  1. 출력 디렉터리를 오프라인 인스턴스로 전송합니다.

  2. 오프라인 인스턴스에서 GitLab Rails 디렉터리의 루트를 찾은 다음, 라이선스 데이터를 제자리로 복사하고 권한을 업데이트합니다.

    export GITLAB_RAILS_ROOT_DIR="$(sudo gitlab-rails runner 'puts Rails.root.to_s')"
    echo $GITLAB_RAILS_ROOT_DIR
    export LICENSES_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses"
    sudo rm -rf "$LICENSES_DIR/v3.old" "$LICENSES_DIR/v3.incoming"
    sudo mkdir -p "$LICENSES_DIR/v3" "$LICENSES_DIR/v3.incoming"
    sudo rsync --recursive ./licenses/v3/ "$LICENSES_DIR/v3.incoming/"
    sudo mv "$LICENSES_DIR/v3" "$LICENSES_DIR/v3.old" && \
      sudo mv "$LICENSES_DIR/v3.incoming" "$LICENSES_DIR/v3" && \
      sudo rm -rf "$LICENSES_DIR/v3.old"
    sudo chmod -R 755 "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/"
    

    이 명령은 v3 디렉터리에만 기록하므로 기존 v2 디렉터리는 사용되지 않은 채 디스크에 남습니다.

    인스턴스는 v3 디렉터리가 생기는 즉시 그 디렉터리에서 라이선스를 읽습니다. 동기화 작업은 5분마다 실행되므로 v3에 직접 복사하면, 동기화 작업이 아직 기록 중인 디렉터리를 읽을 수 있고 v3가 없거나 일부만 기록된 상태가 될 수 있습니다. 대신 v3.incoming에 복사한 다음 이름 변경으로 게시합니다.

PackageMetadata::LicensesSyncWorker cron 작업은 5분마다 실행되며 다음 실행에서 라이선스 데이터를 가져옵니다. 권고와 마찬가지로 이 작업은 Admin 영역에서 활성화한 패키지 레지스트리 유형만 가져오므로, 인스턴스에는 가져오지 않는 레지스트리의 데이터가 있을 수 있습니다.

라이선스 데이터를 업데이트하려면 이 절차를 반복합니다. 게시하면 v3 디렉터리 전체가 교체되므로, 이후 실행에서 빠진 레지스트리는 업데이트를 받지 못합니다. 해당 레지스트리에 대해 이미 가져온 라이선스 데이터는 데이터베이스에 남아 있으며, 다음 실행에 그 레지스트리를 포함하면 업데이트가 다시 시작됩니다.

v3 라이선스 데이터 형식 버전#

히스토리
  • GitLab 19.4에서 도입되었습니다.

v2와 v3는 라이선스 내보내기 파일의 형식 버전이며, 각각 별도 폴더에 게시되고 서로 다른 종류의 라이선스 데이터를 담습니다.

형식 버전 폴더 라이선스 데이터 읽는 버전
v2 licenses/v2/<registry>/ .ndjson 파일의 단일 SPDX 식별자. GitLab 19.3 이전 버전
v3 licenses/v3/<registry>/full_dataset/ 압축 아카이브의 SPDX 라이선스 표현식. GitLab 19.4 이후

형식 버전은 사용자가 아니라 GitLab이 선택합니다. 인스턴스에 두 폴더가 모두 있을 수 있지만, GitLab은 그중 하나만 읽습니다.

GitLab 19.4 이후에서는 sync_v3_license_expressions 플래그가 활성화되어 있고 vendor/package_metadata/licenses 아래에 v3/ 폴더가 있으면 GitLab이 v3를 읽습니다. 두 폴더가 모두 있으면 v3가 우선하고 v2/는 무시됩니다. v2를 읽게 하려면 플래그를 끕니다. 이전 버전은 항상 v2를 읽습니다.

v3 폴더 구조는 다음과 같습니다.

  • GitLab은 데이터를 v3/<registry> 아래에 구성하며, <registry>는 go, rubygem, packagist 같은 이름입니다.

  • 각 레지스트리 폴더에는 full_dataset/ 폴더가 있고, 선택적으로 deltas/ 폴더가 있습니다. full_dataset/은 샤드로 나뉜 전체 스냅샷이며, 스냅샷 타임스탬프가 담긴 checkpoint.json과 샤드 ID로 이름 붙인 샤드별 .tar.zst 아카이브(예: 00.tar.zst)로 구성됩니다. deltas/에는 증분 변경 사항이 들어 있으며, 각 아카이브의 이름은 해당 기간이 끝나는 Unix 타임스탬프입니다.

    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/checkpoint.json
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/00.tar.zst
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/01.tar.zst
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/...
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/deltas/1757520000.tar.zst
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/deltas/1757606400.tar.zst
    
  • 레지스트리의 첫 동기화는 full_dataset/을 읽고 deltas/는 무시합니다. 델타는 전체 데이터 세트를 대신할 수 없기 때문입니다. 이후 동기화는 체크포인트보다 새로운 항목을 오래된 것부터 읽습니다.

    [!warning] 델타 아카이브의 이름은 해당 기간의 끝만 나타내므로, 누락이 있는 deltas/ 폴더도 완전한 폴더와 똑같아 보이며 GitLab은 오류를 보고하지 않습니다. 다운로드 스크립트가 생성한 deltas/ 폴더 전체를 복사하고, 그 안의 일부 아카이브만 골라 복사하지 않습니다.

  • 동기화 작업은 정해진 시간이 지나면 중단되므로 큰 레지스트리는 여러 번 실행해야 합니다. 스냅샷은 첫 번째 동기화뿐 아니라 모든 동기화에서 샤드 단위로 재개되므로, 중단된 실행은 최대 샤드 하나만 다시 읽습니다. 델타 아카이브는 통째로 재개되므로, 중단된 아카이브는 다음 실행에서 처음부터 다시 읽습니다. 큰 델타를 스냅샷처럼 샤드로 나누는 작업은 이슈 608196에서 추적합니다.

Helm 차트로 설치한 인스턴스#

이 절차는 Linux 패키지 설치의 Rails 디렉터리에 기록합니다. 동기화 작업은 Sidekiq에서 실행됩니다. GitLab Helm 차트를 사용하는 설치에서는 같은 파일이 Sidekiq 파드가 /srv/gitlab/vendor/package_metadata에서 읽는 볼륨에 있어야 합니다. toolbox 파드에만 마운트한 볼륨으로는 충분하지 않습니다. 동기화는 여전히 Sidekiq에서 실행되고, 여전히 디렉터리를 찾지 못하며, 실행할 때마다 네트워크 경로를 시도합니다.

차트 values의 gitlab.sidekiq 아래에 볼륨을 선언합니다.

gitlab:
  sidekiq:
    extraVolumes: |
      - name: package-metadata
        persistentVolumeClaim:
          claimName: gitlab-package-metadata
          readOnly: true
    extraVolumeMounts: |
      - name: package-metadata
        mountPath: /srv/gitlab/vendor/package_metadata
        readOnly: true

다른 설정은 필요하지 않습니다. GitLab은 디렉터리의 존재 여부로 오프라인 경로를 선택합니다. 변경 사항은 재시작이나 Rake 작업 없이 다음 동기화 실행부터 적용됩니다.

모든 Sidekiq 파드가 클레임을 마운트하므로 ReadOnlyMany처럼 이를 허용하는 액세스 모드를 볼륨에 지정합니다. GitLab은 이 파일을 읽기만 합니다.

values에서 다음 두 가지 실수에 주의합니다.

  • extraVolumes와 extraVolumeMounts는 YAML 목록이 아니라 문자열로 작성합니다. 목록으로 작성하면 템플릿 이름이 표시되는 타입 오류와 함께 렌더링에 실패합니다. 키를 잘못된 수준에 둔 values 파일은 오히려 아무 표시 없이 실패합니다. Helm은 인식하지 못하는 키를 무시하고, Deployment는 마운트 없이 렌더링되며, 명령은 성공합니다. 렌더링이 성공했다고 해서 마운트가 반영되었다는 증거는 아닙니다. 마운트가 Deployment에 반영되었는지 확인합니다.

    kubectl get deployment -l app=sidekiq -o yaml | grep -c 'vendor/package_metadata'
    

    차트는 이 문자열을 Helm 템플릿 엔진에 통과시키므로, 볼륨 이름이나 마운트 경로에 {{가 있으면 이스케이프합니다.

  • 마운트는 kubectl set env나 kubectl patch가 아니라 values에 넣어야 합니다. 다음 helm upgrade가 그 설정을 덮어씁니다. 저장된 구성에서 조정하는 배포에서는 다음 조정 때 인스턴스가 네트워크 경로로 돌아갑니다. 오프라인 환경에서는 취약점 데이터가 전혀 없다는 뜻입니다.

가져오기 진행 상황을 확인하려면 문제 해결에서 설명하는 대로 Webservice 파드가 아니라 Sidekiq 파드에서 기록된 시퀀스와 로그를 읽습니다.

문제 해결#

누락된 데이터베이스 데이터#

의존성 목록, 취약점 보고서, 머지 리퀘스트 페이지에 라이선스 또는 권고 데이터가 없다면 데이터베이스가 내보내기 데이터와 동기화되지 않았을 수 있습니다.

활성화된 패키지 레지스트리 유형 확인#

package_metadata 동기화는 cron 작업(권고 동기화 및 라이선스 동기화)으로 실행됩니다. 관리자 설정에서 활성화한 패키지 레지스트리 유형만 가져옵니다.

예를 들어 maven을 선택하고 golang을 선택하지 않았다면 Maven의 권고와 라이선스 정보만 표시됩니다.

올바른 파일 구조 확인#

vendor/package_metadata의 파일 구조는 앞에서 활성화한 패키지 레지스트리 유형과 일치해야 합니다. 예를 들어 maven 라이선스 또는 권고 데이터를 동기화하려면 Rails 디렉터리 아래의 패키지 메타데이터 디렉터리가 다음 구조여야 하며, 여기서 $GITLAB_RAILS_ROOT_DIR는 gitlab-rails runner 'puts Rails.root.to_s' 명령의 출력과 일치해야 합니다.

  • v2 라이선스(GitLab 19.3 이전 버전): $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v2/maven/**/*.ndjson.
  • v3 라이선스(GitLab 19.4 이후): $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/maven/full_dataset/*. v3 라이선스 데이터 형식 버전을 참고합니다.
  • v2 권고: $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories/v2/maven/**/*.ndjson.

CVE 보강 데이터는 패키지 레지스트리 유형별로 나뉘지 않습니다. GitLab 19.5 이후에서는 GitLab이 버킷을 복사된 그대로 만든 사본을 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2/cve_enrichment/*/*.ndjson에서 읽습니다. 그 cve_enrichment 디렉터리가 있으면 GitLab은 그 디렉터리만 읽습니다. GitLab 19.4 이전 버전이거나 그 디렉터리가 없는 경우, CVE 보강 파일은 버전 디렉터리 아래 정확히 두 디렉터리 수준에 있어야 합니다.

  • CVE 보강: $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2/*/*.ndjson.

GitLab 19.4 이전 버전에서 CVE 보강 버킷을 그대로 복사하면 파일이 필요한 것보다 한 수준 깊은 곳에 놓이며, GitLab은 그 파일을 하나도 읽지 못합니다. 이를 바로잡으려면 다운로드 절차의 CVE 보강 단계를 참고합니다.

Rails 콘솔에서 GitLab이 파일 경로를 인식하는지 확인할 수 있습니다.

  • 라이선스: sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::LICENSES_PATH)"
  • 권고: sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::ADVISORIES_PATH)"
  • CVE 보강: sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::CVE_ENRICHMENT_PATH)"

위 명령이 false를 반환하면 GitLab이 예상 패키지 경로를 찾지 못한 것입니다. 경로의 모든 폴더와 파일에는 755 권한이 있어야 합니다. 권한을 업데이트하려면 다음을 실행합니다.

sudo chmod -R 755 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/

데이터 확인#

동기화 작업이 성공적으로 실행되면 데이터베이스의 pm_ 테이블에 데이터가 채워져 있어야 합니다.

Rails 콘솔에서 공급업체별 패키지 수를 확인해 이를 검증할 수 있습니다. 예를 들어 Maven 라이선스 및 권고 데이터가 로드되었는지 확인하려면 다음을 실행합니다.

  • 라이선스: sudo gitlab-rails runner "puts \"Package model has #{PackageMetadata::Package.where(purl_type: 'maven').size} packages\""
  • 권고: sudo gitlab-rails runner "puts \"Advisory model has #{PackageMetadata::AffectedPackage.where(purl_type: 'maven').size} packages\""

또한 동기화 중인 패키지 레지스트리에는 체크포인트 데이터가 있어야 합니다. 예를 들어 Maven은 동기화 실행이 성공한 뒤 체크포인트가 생성되어 있어야 합니다.

  • 라이선스: sudo gitlab-rails runner "puts \"maven data has been synced up to #{PackageMetadata::Checkpoint.where(data_type: 'licenses', purl_type: 'maven')}\""
  • 권고: sudo gitlab-rails runner "puts \"maven data has been synced up to #{PackageMetadata::Checkpoint.where(data_type: 'advisories', purl_type: 'maven')}\""

로그#

application_json.log 파일은 동기화 작업이 실행되었는지, 그리고 오류 없이 실행되었는지 확인하는 데 도움이 됩니다. 동기화와 관련된 이벤트는 심각도가 DEBUG이고 클래스는 PackageMetadata::SyncService입니다. 예시: {"severity":"DEBUG","time":"2026-01-07T02:15:49.618Z","meta.caller_id":"PackageMetadata::AdvisoriesSyncWorker","correlation_id":"43008e30dd708eadbe1ab16ad7fa953f","meta.root_caller_id":"Cronjob","meta.feature_category":"software_composition_analysis","meta.client_id":"ip/","class":"PackageMetadata::SyncService","message":"Evaluating data for advisories:offline//opt/gitlab/embedded/service/gitlab-rails/vendor/package_metadata/advisories/v2/maven/1761761049/0.ndjson"}

sidekiq 로그에는 동기화 작업 중 발생한 오류가 표시됩니다. 동기화에 대해 기록된 이벤트에는 다음 관련 클래스가 언급됩니다.

  • 라이선스: PackageMetadata::LicensesSyncWorker
  • 권고: PackageMetadata::AdvisoriesSyncWorker

누락된 v3 라이선스 데이터#

인스턴스로 복사한 뒤에도 라이선스 데이터가 없다면, 동기화 작업이 디렉터리를 찾지 못했거나 가져올 새 항목을 찾지 못한 것입니다. 두 경우 모두 아무 표시가 없으므로 오류를 찾는 대신 다음 점검을 차례로 진행합니다.

시작하기 전에 다음을 확인합니다.

v3 라이선스 데이터 확인#

표현식은 라이선스 레코드에 저장됩니다. 동기화된 v3 스냅샷을 확인하려면 표현식이 있는 라이선스가 하나 이상 있는지 확인합니다. Rails 콘솔에서 확인할 수 있습니다.

  • sudo gitlab-rails runner "puts \"#{PackageMetadata::License.where.not(spdx_expression: nil).count} licenses have an expression\""

패키지와 체크포인트는 있는데 표현식이 있는 라이선스가 없다면, 인스턴스가 v3가 아니라 v2 레이아웃을 동기화한 것입니다. 디스크의 데이터가 v3/ 아래에 있는지 확인합니다.

스냅샷은 기록된 체크포인트보다 새로운 경우에만 가져오므로, 같은 스냅샷을 두 번째로 복사해도 아무 효과가 없습니다. 더 새로운 라이선스 데이터를 받으려면 다운로드 스크립트를 다시 실행합니다.

누락된 악성코드 권고 데이터#

인스턴스로 복사한 뒤에도 악성코드 권고가 없다면, 동기화 작업이 디렉터리를 찾지 못했거나 가져올 새 항목을 찾지 못한 것입니다. 두 경우 모두 아무 표시가 없으므로 오류를 찾지 말고 다음 점검을 차례로 진행합니다.

악성코드 권고에 대해 활성화된 패키지 레지스트리 유형 확인#

관리자 설정에서 활성화한 패키지 레지스트리 유형만 가져옵니다. 복사했지만 활성화하지 않은 레지스트리는 읽지 않으며, 아무 로그도 남지 않습니다.

GitLab이 오프라인 디렉터리를 감지하는지 확인#

GitLab은 vendor 디렉터리가 있으면 디스크에서 악성코드 권고를 읽고, 없으면 배포 서비스에서 읽습니다. Rails 콘솔에서 GitLab이 파일 경로를 인식하는지 확인할 수 있습니다.

  • sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::MALWARE_ADVISORIES_PATH)"

명령이 false를 반환하면 GitLab이 예상 경로를 찾지 못한 것입니다. 경로의 모든 폴더와 파일에는 755 권한이 있어야 합니다. Kubernetes 설치에서는 Sidekiq과 같은 볼륨을 마운트한 파드에서 명령을 실행합니다. 그렇지 않으면 결과가 동기화 작업이 읽는 내용을 반영하지 않습니다.

악성코드 권고 파일 구조 확인#

디렉터리 이름은 패키지 유형이 아니라 패키지 레지스트리 식별자를 따릅니다. 이 식별자는 항상 같지는 않습니다. gem 권고는 rubygem 아래에, golang은 go 아래에, composer는 packagist 아래에 저장됩니다. 패키지 유형으로 이름 붙인 디렉터리는 무시되며 오류도 기록되지 않습니다.

또한 각 full_dataset 디렉터리에는 checkpoint.json이 있어야 합니다. GitLab은 체크포인트가 없는 디렉터리를 무시하며, 이는 중단된 다운로드가 가져와지는 것을 막습니다.

악성코드 권고 데이터 확인#

동기화 작업이 성공적으로 실행되면 악성코드 권고가 채워져 있어야 합니다. Rails 콘솔에서 권고 수를 세어 확인할 수 있습니다.

  • sudo gitlab-rails runner "puts \"Malware advisory model has #{PackageMetadata::MalwareAdvisory.count} advisories\""

복사한 패키지 레지스트리에도 체크포인트 데이터가 있어야 합니다.

  • sudo gitlab-rails runner "puts PackageMetadata::Checkpoint.where(data_type: 'malware_advisories').pluck(:purl_type, :sequence).to_h"

스냅샷은 기록된 체크포인트보다 새로운 경우에만 가져오므로, 같은 스냅샷을 두 번째로 복사해도 아무 효과가 없습니다.

악성코드 권고 로그#

악성코드 권고 동기화와 관련된 이벤트는 application_json.log에 INFO 심각도로 기록됩니다. 클래스는 동기화 자체에는 PackageMetadata::MalwareAdvisorySyncService, 가져오기에는 PackageMetadata::MalwareAdvisoryIngestionService입니다. 동기화는 Sidekiq에서 실행되므로 Kubernetes 설치에서는 이 이벤트가 Webservice 파드가 아니라 Sidekiq 파드의 subcomponent="application_json" 키 아래에 있습니다.

sidekiq 로그에는 발생한 오류가 표시되며, PackageMetadata::MalwareAdvisoriesSyncWorker 클래스에 대해 기록됩니다.

각 레지스트리는 실행마다 started 이벤트와 completed 이벤트를 기록합니다. files_ingested: 0이 포함된 completed 이벤트는 해당 실행에서 가져올 항목을 찾지 못했다는 뜻입니다. 해당 이벤트의 storage_type 필드로 어느 경우인지 알 수 있습니다. offline은 GitLab이 vendor 디렉터리를 읽었다는 뜻이고, pds는 디렉터리를 찾지 못해 배포 서비스를 시도했다는 뜻입니다. Kubernetes에서는 이 필드가 동기화를 실행하는 프로세스에서 나오므로 앞의 File.exist? 확인보다 더 신뢰할 수 있습니다.

PackageMetadata::MalwareAdvisorySyncService 이벤트가 전혀 나타나지 않으면 심각도가 DEBUG인 PackageMetadata::MalwareAdvisoriesSyncWorker 이벤트를 찾습니다. 이 이벤트에는 dependency_scanning 라이선스와 의존성 방화벽 중 어느 것도 사용할 수 없는 경우처럼 실행이 시작되지 않은 이유가 나와 있습니다.

큰 레지스트리의 첫 가져오기는 몇 분 동안 실행될 수 있으며, 그동안 cron은 5분마다 다시 실행됩니다. 겹친 실행은 각각 application_json.log에 ERROR 심각도로 Cannot obtain an exclusive lease. There must be another instance already in execution.를 기록한 뒤 종료합니다. 이 리스가 두 동기화가 동시에 실행되는 것을 막으므로, 긴 가져오기 중에는 이 이벤트가 예상되며 동기화가 실패했다는 뜻이 아닙니다.

오프라인 인스턴스용 Package Metadata Database

GitLab v19.5
Tier: Ultimate
Offering: GitLab Self-Managed
원문 보기

요약

지속적 취약점 스캔과 CycloneDX 파일의 라이선스 스캔을 사용하려면 Package Metadata Database를 활성화해야 합니다. Package Metadata Database는 라이선스, 권고, 악성코드 권고, CVE 보강의 네 가지 데이터 세트로 구성됩니다.

지속적 취약점 스캔과 CycloneDX 파일의 라이선스 스캔을 사용하려면 Package Metadata Database를 활성화해야 합니다. 이 과정에는 통틀어 Package Metadata Database라고 부르는 라이선스 및 권고 데이터가 사용되며, 이 데이터는 EE 라이선스에 따라 사용이 허가됩니다. Package Metadata Database 사용과 관련해 다음 사항에 유의합니다.

  • GitLab은 언제든지 사전 고지 없이 단독 재량으로 Package Metadata Database의 전부 또는 일부를 변경하거나 중단할 수 있습니다.
  • Package Metadata Database에는 서드 파티 웹사이트나 리소스로 연결되는 링크가 포함될 수 있습니다. 이 링크는 편의를 위해서만 제공하며, GitLab은 해당 웹사이트나 리소스, 또는 그곳에 표시된 링크에서 제공하는 서드 파티 데이터·콘텐츠·제품·서비스에 책임을 지지 않습니다.
  • Package Metadata Database는 일부 서드 파티가 공개한 정보를 기반으로 하며, GitLab은 제공되는 콘텐츠의 정확성이나 완전성에 책임을 지지 않습니다.

Package Metadata Database는 라이선스, 권고, 악성코드 권고, CVE 보강의 네 가지 데이터 세트로 구성됩니다. 패키지 메타데이터는 v2와 v3 두 가지 형식 버전으로 게시되며, 각 데이터 세트는 둘 중 하나를 사용합니다. 두 형식 버전은 담고 있는 데이터가 다르고 다운로드 방식도 다르므로, 아래에서 각각 별도의 절차를 설명합니다. 각 데이터 세트와 GitLab 버전이 어느 형식 버전을 사용하는지는 Package Metadata Database를 참고합니다.

v2 데이터는 GitLab이 관리하고 소유하는 다음의 공개 Google Cloud Provider(GCP) 버킷에 저장됩니다.

  • License Scanning: prod-export-license-bucket-1a6c642fc4de57d4
  • Dependency scanning: prod-export-advisory-bucket-1a6c642fc4de57d4
  • CVE enrichment: prod-export-cve-enrichment-bucket-1a6c642fc4de57d4

v3 데이터에는 공개 버킷이 없습니다. 인증이 필요한 GitLab 서비스를 통해 배포되므로 다운로드하려면 라이선스 키가 필요합니다. v3 Package Metadata Database 데이터 다운로드를 참고합니다.

CVE 보강 데이터에는 취약점에 표시되는 EPSS 점수와 KEV 상태가 들어 있습니다. 라이선스 버킷과 권고 버킷만 동기화하는 인스턴스에는 의존성 스캔 데이터와 라이선스 데이터는 있지만 위험 평가 데이터는 없습니다.

Note

모든 데이터 세트에 대해 GitLab은 vendor/package_metadata 아래의 디렉터리가 있으면 데이터를 직접 다운로드하지 않고 그 디렉터리를 우선해서 읽습니다. 디렉터리가 있으면 항상 그 디렉터리가 우선하며, 온라인 동기화로 대체되지 않습니다. 따라서 오래된 데이터가 들어 있는 디렉터리는 오류 보고 없이 오래된 데이터를 제공합니다. 악성코드 권고도 같은 규칙을 따릅니다.

첫 로드 전 준비#

무엇이든 다운로드하기 전에 동기화할 패키지 레지스트리 유형을 정합니다. 관리자 설정에서 활성화한 유형만 가져옵니다. 비활성화한 유형을 다운로드하면 시간과 디스크 공간만 소모되고, 그 데이터는 데이터베이스에 반영되지 않습니다.

가져오기 속도는 인스턴스의 리소스가 아니라 동기화 작업의 스로틀이 정하므로, 더 큰 인스턴스라고 해서 더 빠르게 가져오지는 않습니다. 또한 각 실행은 정해진 시간이 지나면 중단되고, 다음 실행에서 체크포인트부터 다시 시작합니다. 모든 레지스트리 유형을 활성화한 상태의 첫 로드에는 오랜 시간이 걸립니다. 시간을 줄이려면 활성화한 유형을 좁힙니다.

gsutil로 v2 패키지 메타데이터 다운로드#

이 절에서는 v2 데이터를 다운로드하는 방법을 설명합니다.

  1. gsutil 도구를 설치합니다.

  2. GitLab Rails 디렉터리의 루트를 찾습니다.

    export GITLAB_RAILS_ROOT_DIR="$(gitlab-rails runner 'puts Rails.root.to_s')"
    echo $GITLAB_RAILS_ROOT_DIR
    
  3. 동기화할 데이터 유형을 설정합니다.

    # For License Scanning
    export PKG_METADATA_BUCKET=prod-export-license-bucket-1a6c642fc4de57d4
    export DATA_DIR="licenses"
    
    # For dependency scanning
    export PKG_METADATA_BUCKET=prod-export-advisory-bucket-1a6c642fc4de57d4
    export DATA_DIR="advisories"
    
    # For CVE enrichment
    export PKG_METADATA_BUCKET=prod-export-cve-enrichment-bucket-1a6c642fc4de57d4
    export DATA_DIR="cve_enrichment"
    
  4. 패키지 메타데이터 내보내기 파일을 다운로드합니다.

    # To download the package metadata exports, an outbound connection to Google Cloud Storage bucket must be allowed.
    mkdir -p "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
    gsutil -m rsync -r -d gs://$PKG_METADATA_BUCKET "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
    
    # Alternatively, if the GitLab instance is not allowed to connect to the Google Cloud Storage bucket, the package metadata
    # exports can be downloaded using a machine with the allowed access, and then copied to the root of the GitLab Rails directory.
    rsync rsync://example_username@gitlab.example.com/package_metadata/$DATA_DIR "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/$DATA_DIR"
    
  5. GitLab 19.4 이전 버전에서 CVE 보강 데이터를 사용하는 경우, 다운로드한 파일을 한 디렉터리 수준 위로 옮깁니다. GitLab 19.5 이후에서는 이 단계를 건너뜁니다. 자세한 내용은 머지 리퀘스트 253401을 참고합니다.

    라이선스 버킷과 권고 버킷에는 v2/ 아래에 패키지 레지스트리마다 디렉터리가 하나씩 있으며, GitLab은 이를 복사된 그대로 읽습니다. CVE 보강 데이터에는 패키지 레지스트리가 없으며, 그 버킷에는 해당 위치에 cve_enrichment 디렉터리 하나만 있습니다. GitLab 19.4 이전 버전에서 GitLab은 CVE 보강 데이터를 cve_enrichment/v2/<sequence>/<chunk>.ndjson 경로에서만, 정확히 그 깊이에서만 읽습니다. 그대로 복사하면 파일이 한 디렉터리 수준 더 깊은 곳에 놓입니다. 이 경우 GitLab은 아무것도 가져오지 않으며 로그도 남기지 않습니다.

    cd "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2"
    cp -a cve_enrichment/. . && rm -r cve_enrichment
    

    CVE 보강 내보내기 파일을 다운로드할 때마다 이 단계를 반복합니다. cp -a는 이미 있는 시퀀스 디렉터리에 새 파일을 병합하지만, mv는 그 경우 중단됩니다.

Google Cloud Storage REST API로 v2 패키지 메타데이터 다운로드#

이 절에서는 v2 패키지 메타데이터를 다운로드하는 다른 방법을 설명합니다.

패키지 메타데이터 내보내기 파일은 Google Cloud Storage API로도 다운로드할 수 있습니다. 내용은 https://storage.googleapis.com/storage/v1/b/prod-export-license-bucket-1a6c642fc4de57d4/o와 https://storage.googleapis.com/storage/v1/b/prod-export-advisory-bucket-1a6c642fc4de57d4/o에서 확인할 수 있습니다. 다음은 cURL과 jq를 사용해 다운로드하는 예시입니다.

#!/bin/bash

set -euo pipefail

DATA_TYPE=$1

GITLAB_RAILS_ROOT_DIR="$(gitlab-rails runner 'puts Rails.root.to_s')"

if [ "$DATA_TYPE" == "license" ]; then
  PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses"
elif [ "$DATA_TYPE" == "advisory" ]; then
  PKG_METADATA_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories"
else
  echo "Usage: import_script.sh [license|advisory]"
  exit 1
fi

PKG_METADATA_BUCKET="prod-export-$DATA_TYPE-bucket-1a6c642fc4de57d4"
PKG_METADATA_DOWNLOADS_OUTPUT_FILE="/tmp/package_metadata_${DATA_TYPE}_object_links.tsv"

# Download the contents of the bucket
# The script downloads all the objects and creates files with a maximum 1000 objects per file in JSON format.

MAX_RESULTS=1000
TEMP_FILE="out.json"

curl --silent --show-error --request GET "https://storage.googleapis.com/storage/v1/b/$PKG_METADATA_BUCKET/o?maxResults=$MAX_RESULTS" >"$TEMP_FILE"
NEXT_PAGE_TOKEN="$(jq -r '.nextPageToken' $TEMP_FILE)"
jq -r '.items[] | [.name, .mediaLink] | @tsv' "$TEMP_FILE" >"$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"

while [ "$NEXT_PAGE_TOKEN" != "null" ]; do
  curl --silent --show-error --request GET "https://storage.googleapis.com/storage/v1/b/$PKG_METADATA_BUCKET/o?maxResults=$MAX_RESULTS&pageToken=$NEXT_PAGE_TOKEN" >"$TEMP_FILE"
  NEXT_PAGE_TOKEN="$(jq -r '.nextPageToken' $TEMP_FILE)"
  jq -r '.items[] | [.name, .mediaLink] | @tsv' "$TEMP_FILE" >>"$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"
  #use for API rate-limiting
  sleep 1
done

trap 'rm -f "$TEMP_FILE"' EXIT

echo "Fetched $DATA_TYPE export manifest"

# Parse the links and names for the bucket objects and output them into a tsv file

echo -e "Saving package metadata exports to $PKG_METADATA_DIR\n"

# Track how many objects will be downloaded
INDEX=1
TOTAL_OBJECT_COUNT="$(wc -l "$PKG_METADATA_DOWNLOADS_OUTPUT_FILE" | awk '{print $1}')"

# Download the objects
while IFS= read -r line; do
  FILE="$(echo -n "$line" | awk '{print $1}')"
  URL="$(echo -n "$line" | awk '{print $2}')"
  OUTPUT_PATH="$PKG_METADATA_DIR/$FILE"

  echo "Downloading $FILE"

  if [ ! -f "$OUTPUT_PATH" ]; then
    curl --progress-bar --create-dirs --output "$OUTPUT_PATH" --request "GET" "$URL"
  else
    echo "Existing file found"
  fi

  echo -e "$INDEX of $TOTAL_OBJECT_COUNT objects downloaded\n"

  INDEX=$((INDEX + 1))
done <"$PKG_METADATA_DOWNLOADS_OUTPUT_FILE"

echo "All objects saved to $PKG_METADATA_DIR"

일정에 따라 v2 패키지 메타데이터 다운로드#

GitLab 인스턴스는 package_metadata 디렉터리의 내용과 정기적으로 동기화됩니다. 업스트림 변경 사항으로 로컬 사본을 자동으로 업데이트하려면 cron 작업을 추가해 새 내보내기 파일을 주기적으로 다운로드할 수 있습니다. 예를 들어 다음 crontab을 추가하면 30분마다 실행되는 cron 작업을 설정할 수 있습니다.

라이선스 작업과 권고 작업은 -y "^v3\/"를 전달해 v3/ 폴더를 제외하므로 v2 형식 버전만 미러링합니다.

v2 라이선스 스캔의 경우(GitLab 19.3 이전 버전):

*/30 * * * * gsutil -m rsync -r -d -y "^v3\/" gs://prod-export-license-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses

v2 의존성 스캔의 경우:

*/30 * * * * gsutil -m rsync -r -d -y "^v3\/" gs://prod-export-advisory-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories

GitLab 19.4 이전 버전의 CVE 보강 데이터는 -d를 생략하고, 다운로드 절차의 이동 단계를 이어 붙입니다.

*/30 * * * * gsutil -m rsync -r gs://prod-export-cve-enrichment-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment && cd $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2 && cp -a cve_enrichment/. . && rm -r cve_enrichment

이동 단계를 거치면 로컬 구조가 버킷과 달라지므로 -d를 쓰면 이동한 파일이 삭제됩니다. 또한 이동 단계는 gsutil rsync가 비교 대상으로 삼는 디렉터리도 제거하므로, -d의 유무와 관계없이 매 실행마다 내보내기 파일 전체를 다시 다운로드합니다.

GitLab 19.5 이후에서는 이동 단계 없이 다운로드를 실행합니다. 이전 버전에서 업그레이드하는 경우 기존 CVE 보강 줄을 다음으로 바꿉니다.

*/30 * * * * gsutil -m rsync -r -d gs://prod-export-cve-enrichment-bucket-1a6c642fc4de57d4 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment
Warning

라이선스 및 권고 예시와 GitLab 19.5 이후의 CVE 보강 예시에 있는 -d 플래그는 버킷에 더 이상 없는 로컬 파일을 삭제하며, 이로 인해 진행 중인 가져오기가 사라질 수 있습니다. GitLab은 데이터 유형과 패키지 레지스트리마다 마지막으로 가져온 파일을 기록하고, 그 다음 파일부터 다시 시작합니다. 정리 작업으로 그 기록이 가리키는 시퀀스 디렉터리가 삭제되면 GitLab은 해당 기록을 찾지 못합니다. 그러면 해당 데이터 유형 전체를 처음부터 다시 가져옵니다. 정리하기 전에 각 데이터 유형의 기록된 시퀀스가 디스크에 아직 있는지 확인합니다. 기록된 시퀀스를 읽는 방법은 데이터 확인을 참고합니다. 둘 이상의 인스턴스가 읽는 디렉터리에서는 인스턴스 중 가장 오래된 기록 시퀀스보다 앞의 항목만 정리합니다.

이 동작은 GitLab이 파일을 한 번에 하나씩 가져오는 라이선스, 권고, CVE 보강 내보내기 파일에 적용됩니다. 악성코드 권고 절차는 다릅니다. 이 절차는 매 실행마다 전체 스냅샷을 교체하며, 여기서는 rsync --delete가 필수입니다.

v3 Package Metadata Database 데이터 다운로드#

v3 라이선스와 악성코드 권고는 공개 버킷에 없습니다. 이 데이터는 인증이 필요한 GitLab 서비스인 Package Metadata Database 배포 서비스(PDS)에서 제공하므로 오프라인 인스턴스가 직접 다운로드할 수 없습니다. 대신 인터넷에 연결된 머신에서 다음 스크립트를 실행한 다음, 그 데이터를 오프라인 인스턴스로 복사합니다. 이 스크립트는 두 데이터 세트 모두에 사용할 수 있습니다. 첫 번째 인수로 데이터 세트 이름을 전달합니다.

스크립트는 각 패키지 레지스트리에 대해, 디스크에 이미 있는 스냅샷 이후에 게시된 델타를 다운로드합니다. 스냅샷이 없는 경우, 그리고 레지스트리가 델타 방식보다 새 스냅샷이 더 효율적일 만큼 많이 뒤처진 경우에는 새 스냅샷을 다운로드합니다. 레지스트리의 체크포인트는 해당 레지스트리의 아카이브를 모두 다운로드한 뒤 마지막에 기록하므로, 일부만 다운로드된 상태가 완료된 것으로 오인되는 일은 없습니다. 스크립트는 라이선스 키를 3일간 유효한 토큰으로 교환합니다. 인터넷에 연결된 머신에는 라이선스 파일의 사본이 필요하지만, 오프라인 인스턴스에 접근할 필요는 없습니다.

사전 요구 사항:

  • 관리자 액세스 권한.
  • 오프라인 라이선스. 레거시 라이선스 파일은 작동하지 않습니다. 토큰 요청이 Invalid cloud license로 실패하고 스크립트가 중단됩니다. 라이선스 유형은 Admin 영역 대시보드의 License overview에서 확인할 수 있습니다.
  • 인터넷에 연결된 머신에는 cURL, jq 1.6 이상, 그리고 customers.gitlab.com, pmdb-dist-svc.runway.gitlab.net, storage.googleapis.com에 대한 아웃바운드 액세스가 필요합니다. 배포 서비스는 gitlab.net 도메인에 있으므로 gitlab.com으로만 제한한 허용 목록으로는 접근할 수 없습니다.
  • 오프라인 인스턴스에는 rsync.

오프라인 라이선스를 받으려면 승인이 필요한 클라우드 라이선스 옵트아웃 면제를 받아야 합니다. 자세한 내용은 GitLab 영업 담당자에게 문의합니다. 이미 오프라인 라이선스가 있다면 고객 포털에서 파일을 다시 다운로드할 수 있습니다.

스크립트에는 라이선스 파일에 들어 있는 긴 텍스트 블록인 라이선스 키가 필요합니다. 이를 파일로 저장하려면 오프라인 인스턴스에서 다음을 실행합니다.

sudo gitlab-rails runner 'puts License.current.data' > Gitlab.gitlab-license

활성화 코드는 라이선스 키가 아닙니다. 활성화 코드는 짧은 문자열이며 토큰 요청에서 거부됩니다.

이 단계는 Linux 패키지 설치에 적용됩니다. Kubernetes 설치에서는 데이터가 Sidekiq 파드가 읽는 볼륨에 있어야 합니다. Helm 차트로 설치한 인스턴스를 참고합니다.

Warning

Docker 설치에서는 Rails 디렉터리가 마운트된 볼륨에 있지 않습니다. 데이터를 복사하기 전에 vendor/package_metadata를 바인드 마운트합니다. 그렇지 않으면 컨테이너를 업그레이드할 때 데이터가 사라집니다.

다음 스크립트를 download_pmdb_data.sh로 저장하고 chmod +x download_pmdb_data.sh로 실행 권한을 부여합니다.

#!/bin/bash

set -euo pipefail

CDOT_URL="${CDOT_URL:-https://customers.gitlab.com}"
PDS_URL="${PDS_URL:-https://pmdb-dist-svc.runway.gitlab.net}"

# A registry far enough behind is cheaper to replace with a fresh snapshot than
# to catch up one delta at a time. Set to 0 to never download a delta.
MAX_DELTAS="${MAX_DELTAS:-100}"

if [ $# -lt 5 ]; then
  echo "Usage: download_pmdb_data.sh <license_file> <gitlab_version> <dataset> <output_dir> <registry>"
  echo "dataset is licenses or malware_advisories."
  echo "Pass the package registries to download separated by space, or 'all' for every supported registry."
  exit 1
fi

LICENSE_FILE=$1
GITLAB_VERSION=$2
DATASET=$3
OUTPUT_DIR=$4
shift 4
REQUESTED_REGISTRIES="$*"

# The two datasets differ only in their distribution service path and the name
# used in the closing message. Everything after this point is common to both.
case "$DATASET" in
licenses)
  DATASET_PATH="licenses"
  DATASET_LABEL="License data"
  ;;
malware_advisories)
  DATASET_PATH="malware/advisories"
  DATASET_LABEL="Advisories"
  ;;
*)
  echo "dataset must be licenses or malware_advisories"
  exit 1
  ;;
esac

if [ -z "$OUTPUT_DIR" ]; then
  echo "output_dir must not be empty"
  exit 1
fi

if [ ! -r "$LICENSE_FILE" ]; then
  echo "Cannot read $LICENSE_FILE"
  exit 1
fi

mkdir -p "$OUTPUT_DIR"

# Serialize runs against this output directory. Two concurrent runs would race
# on the same dataset directory: one can write checkpoint.json while the other
# has deleted and re-created the directory, leaving a partial shard set that
# GitLab imports as a complete snapshot. mkdir either creates the directory or
# fails, in one step, so it needs nothing beyond the shell.
LOCK_DIR="$OUTPUT_DIR/.download.lock"

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  echo "Another download is already running for $OUTPUT_DIR"
  echo "If no other run is active, remove $LOCK_DIR and try again."
  exit 1
fi

REQUEST_FILE="$(mktemp)"
RESPONSE_FILE="$(mktemp)"
SNAPSHOT_FILE="$(mktemp)"
SHARDS_FILE="$(mktemp)"
DELTAS_FILE="$(mktemp)"
HEADER_FILE="$(mktemp)"
trap 'rm -f "$REQUEST_FILE" "$RESPONSE_FILE" "$SNAPSHOT_FILE" "$SHARDS_FILE" "$DELTAS_FILE" "$HEADER_FILE"; rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
chmod 600 "$HEADER_FILE"

# Exchange the license key for a Cloud Connector token.
GRAPHQL_QUERY='query($licenseKey: String!, $gitlabVersion: String!) {
  cloudConnectorAccess(licenseKey: $licenseKey, gitlabVersion: $gitlabVersion) {
    serviceToken { token }
  }
}'

jq --null-input --arg query "$GRAPHQL_QUERY" --rawfile licenseKey "$LICENSE_FILE" \
  --arg gitlabVersion "$GITLAB_VERSION" \
  '{query: $query, variables: {licenseKey: $licenseKey, gitlabVersion: $gitlabVersion}}' >"$REQUEST_FILE"

HTTP_STATUS="$(curl --silent --show-error --request POST "$CDOT_URL/graphql" \
  --header 'Content-Type: application/json' --data @"$REQUEST_FILE" \
  --output "$RESPONSE_FILE" --write-out '%{http_code}')"

if [ "$HTTP_STATUS" != "200" ]; then
  echo "Token request to $CDOT_URL failed with HTTP $HTTP_STATUS"
  head -c 500 "$RESPONSE_FILE"
  exit 1
fi

TOKEN="$(jq --raw-output '.data.cloudConnectorAccess.serviceToken.token // empty' "$RESPONSE_FILE")"

if [ -z "$TOKEN" ]; then
  echo "$CDOT_URL refused to issue a token for $LICENSE_FILE"
  echo "It must hold the license key, the output of: gitlab-rails runner 'puts License.current.data'"
  echo "An activation code is not a license key, and a legacy license is not accepted."
  echo "The customer portal reported:"
  jq --raw-output '.errors[]?.message // "no reason given"' "$RESPONSE_FILE"
  exit 1
fi

# The distribution service requires X-Gitlab-Instance-Id to equal the token's
# subject claim and X-Gitlab-Realm to equal its realm claim. Both are in the
# payload, which is the second dot-separated segment of the token.
CLAIMS="$(printf '%s' "$TOKEN" |
  jq --raw-input 'split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson')"

INSTANCE_ID="$(jq --raw-output '.sub // empty' <<<"$CLAIMS")"
REALM="$(jq --raw-output '.gitlab_realm // empty' <<<"$CLAIMS")"

if [ -z "$INSTANCE_ID" ] || [ -z "$REALM" ]; then
  echo "The token is missing its subject or realm claim"
  exit 1
fi

# Pass the headers through a file so the token never appears in a process
# list. It is readable there by any local user for the download's duration.
{
  printf 'header = "Authorization: Bearer %s"\n' "$TOKEN"
  printf 'header = "X-Gitlab-Instance-Id: %s"\n' "$INSTANCE_ID"
  printf 'header = "X-Gitlab-Realm: %s"\n' "$REALM"
} >"$HEADER_FILE"

# Writes the response body to $2 and returns the HTTP status.
pds_request() {
  curl --silent --show-error --config "$HEADER_FILE" \
    --output "$2" --write-out '%{http_code}' "$1"
}

HTTP_STATUS="$(pds_request "$PDS_URL/v1/$DATASET_PATH/supported" "$RESPONSE_FILE")"

if [ "$HTTP_STATUS" != "200" ]; then
  echo "Request for the supported package registries failed with HTTP $HTTP_STATUS"
  head -c 500 "$RESPONSE_FILE"
  exit 1
fi

SUPPORTED="$(jq --raw-output '.registries[]' "$RESPONSE_FILE")"
SKIPPED=""

if [ -z "$SUPPORTED" ]; then
  echo "No package registries are available"
  exit 1
fi

echo "Available package registries: $(echo "$SUPPORTED" | tr '\n' ' ')"

# Disable filename expansion so a registry name is never treated as a glob.
set -f

if [ "$REQUESTED_REGISTRIES" = "all" ]; then
  REGISTRIES="$SUPPORTED"
else
  REGISTRIES="$REQUESTED_REGISTRIES"

  for REGISTRY in $REGISTRIES; do
    if ! grep --quiet --fixed-strings --line-regexp "$REGISTRY" <<<"$SUPPORTED"; then
      echo "$REGISTRY is not a supported package registry"
      exit 1
    fi
  done
fi

# Downloads the current snapshot into a registry's full_dataset directory, and
# removes any delta archives beside it: a snapshot supersedes every delta older
# than itself, and GitLab reads whatever is newer than its own checkpoint, so
# leaving them would re-apply changes the snapshot already contains.
download_snapshot() {
  REGISTRY=$1
  UNTIL=$2
  DATASET_DIR="$OUTPUT_DIR/v3/$REGISTRY/full_dataset"

  # Remove any previous snapshot. GitLab reads every archive in this directory,
  # so archives left over from an earlier snapshot would be imported alongside
  # the new ones, restoring data that changed since.
  rm -rf "$DATASET_DIR" "$OUTPUT_DIR/v3/$REGISTRY/deltas"
  mkdir -p "$DATASET_DIR"

  # Read the shards from the saved /all body. The delta loop reuses
  # $RESPONSE_FILE, so by the time a fallback lands here it no longer holds it.
  jq --raw-output '.shards[] | [.shard, (.signed_url // .url)] | @tsv' "$SNAPSHOT_FILE" >"$SHARDS_FILE"
  SHARD_COUNT="$(wc -l <"$SHARDS_FILE" | tr -d ' ')"

  while IFS=$'\t' read -r SHARD URL; do
    echo "Downloading $REGISTRY snapshot archive $SHARD"
    curl --fail --silent --show-error --location --output "$DATASET_DIR/$SHARD.tar.zst.part" "$URL"
    mv "$DATASET_DIR/$SHARD.tar.zst.part" "$DATASET_DIR/$SHARD.tar.zst"
  done <"$SHARDS_FILE"

  # Write the checkpoint last. GitLab ignores a directory that has no
  # checkpoint, so an interrupted download is never imported as a snapshot.
  jq --null-input --argjson until "$UNTIL" --argjson shards "$SHARD_COUNT" \
    '{until: $until, shards: $shards}' >"$DATASET_DIR/checkpoint.json"
}

for REGISTRY in $REGISTRIES; do
  HTTP_STATUS="$(pds_request "$PDS_URL/v1/$DATASET_PATH/all?purl_type=$REGISTRY" "$RESPONSE_FILE")"

  # A pending snapshot is the only skippable outcome. Every other 503 is
  # transient, and treating it as "no data" would delete a registry that the
  # instance already has.
  if [ "$HTTP_STATUS" = "503" ]; then
    # A 503 from in front of the service has no JSON body, so keep the reason
    # empty rather than letting jq abort the script.
    REASON="$(jq --raw-output '.reason // empty' "$RESPONSE_FILE" 2>/dev/null || true)"

    if [ "$REASON" = "snapshot_not_yet_published" ]; then
      echo "Skipping $REGISTRY, no snapshot is published yet"
      SKIPPED="$SKIPPED $REGISTRY"
      continue
    fi

    echo "Request for $REGISTRY failed with HTTP 503, try again later ($REASON)"
    exit 1
  fi

  if [ "$HTTP_STATUS" != "200" ]; then
    echo "Request for $REGISTRY failed with HTTP $HTTP_STATUS"
    head -c 500 "$RESPONSE_FILE"
    exit 1
  fi

  # Keep the /all body: the snapshot is the fallback for every branch below,
  # and the delta loop writes over $RESPONSE_FILE before one can be taken.
  cp "$RESPONSE_FILE" "$SNAPSHOT_FILE"

  UNTIL="$(jq --raw-output '.until // empty' "$SNAPSHOT_FILE")"

  if [ -z "$UNTIL" ]; then
    echo "The response for $REGISTRY has no snapshot timestamp"
    exit 1
  fi

  REGISTRY_DIR="$OUTPUT_DIR/v3/$REGISTRY"
  LOCAL_UNTIL="$(jq --raw-output '.until // empty' \
    "$REGISTRY_DIR/full_dataset/checkpoint.json" 2>/dev/null || true)"

  # Take the whole snapshot unless the output directory already holds one this
  # run can continue from. A delta cannot bootstrap a registry, and `since` has
  # to be a 10-digit unix-seconds value or the service answers 400, so a
  # checkpoint that is missing, unreadable or in another unit starts over.
  if ! [[ "$LOCAL_UNTIL" =~ ^[0-9]{10}$ ]]; then
    download_snapshot "$REGISTRY" "$UNTIL"
    continue
  fi

  # Resume from the newest archive on disk, snapshot or delta, which is where
  # GitLab's own checkpoint ends up once it has imported them. Asking from an
  # older timestamp would download deltas that are already here. Names that are
  # not a bare timestamp are ignored, matching what the connector reads.
  SINCE="$(find "$REGISTRY_DIR/deltas" -type f -name '*.tar.zst' 2>/dev/null |
    sed -e 's#.*/##' -e 's#\.tar\.zst$##' | grep -E '^[0-9]{10}$' |
    sort -n | tail -1 || true)"

  if [ -z "$SINCE" ] || [ "$LOCAL_UNTIL" -gt "$SINCE" ]; then
    SINCE="$LOCAL_UNTIL"
  fi

  # With deltas disabled, the published snapshot decides everything.
  if [ "$MAX_DELTAS" -le 0 ]; then
    if [ "$UNTIL" -gt "$LOCAL_UNTIL" ]; then
      echo "Replacing $REGISTRY, the published snapshot moved to $UNTIL"
      download_snapshot "$REGISTRY" "$UNTIL"
    else
      echo "Skipping $REGISTRY, snapshot $LOCAL_UNTIL is already downloaded"
    fi
    continue
  fi

  # Page forward. The service caps a response at 20 archives per registry and
  # sends the oldest of the backlog first, so re-asking from the newest archive
  # received walks the whole backlog without gaps.
  : >"$DELTAS_FILE"
  DELTA_TOTAL=0
  CAPPED=""

  while :; do
    HTTP_STATUS="$(pds_request "$PDS_URL/v1/$DATASET_PATH/delta?since=$REGISTRY:$SINCE" "$RESPONSE_FILE")"

    if [ "$HTTP_STATUS" != "200" ]; then
      echo "Delta request for $REGISTRY failed with HTTP $HTTP_STATUS"
      head -c 500 "$RESPONSE_FILE"
      exit 1
    fi

    # Registries are keyed by the identifier /supported gave us, and one that is
    # up to date comes back as an empty array rather than being left out.
    PAGE_COUNT="$(jq --arg r "$REGISTRY" '.purl_types[$r] // [] | length' "$RESPONSE_FILE")"

    if [ "$PAGE_COUNT" -eq 0 ]; then
      break
    fi

    jq --raw-output --arg r "$REGISTRY" \
      '.purl_types[$r] | sort_by(.delta | tonumber) | .[] | [.delta, (.signed_url // .url)] | @tsv' \
      "$RESPONSE_FILE" >>"$DELTAS_FILE"

    DELTA_TOTAL=$((DELTA_TOTAL + PAGE_COUNT))
    SINCE="$(cut -f1 "$DELTAS_FILE" | tail -1)"

    # Past the ceiling, one snapshot is fewer bytes than the rest of the backlog.
    if [ "$DELTA_TOTAL" -gt "$MAX_DELTAS" ]; then
      CAPPED=yes
      break
    fi
  done

  if [ -n "$CAPPED" ]; then
    echo "Replacing $REGISTRY, it is more than $MAX_DELTAS deltas behind"
    download_snapshot "$REGISTRY" "$UNTIL"
    continue
  fi

  if [ "$DELTA_TOTAL" -eq 0 ]; then
    echo "Skipping $REGISTRY, it is up to date at $SINCE"
    continue
  fi

  # Deltas sit beside the snapshot rather than replacing it: GitLab reads the
  # snapshot on a first sync and the deltas on every sync after it.
  mkdir -p "$REGISTRY_DIR/deltas"

  while IFS=$'\t' read -r DELTA URL; do
    echo "Downloading $REGISTRY delta $DELTA"
    curl --fail --silent --show-error --location \
      --output "$REGISTRY_DIR/deltas/$DELTA.tar.zst.part" "$URL"
    mv "$REGISTRY_DIR/deltas/$DELTA.tar.zst.part" "$REGISTRY_DIR/deltas/$DELTA.tar.zst"
  done <"$DELTAS_FILE"

  echo "Downloaded $DELTA_TOTAL delta(s) for $REGISTRY"
done

set +f

if [ -n "$SKIPPED" ]; then
  echo "Warning: no snapshot is published yet for:$SKIPPED"
  echo "The downloaded registries are complete and safe to copy. Run the script again later to pick up the rest."
fi

echo "$DATASET_LABEL saved to $OUTPUT_DIR"

스크립트는 다음과 같이 호출합니다.

./download_pmdb_data.sh <license_file> <gitlab_version> <dataset> <output_dir> <registry>

다음 표는 스크립트의 각 인수가 의미하는 바를 설명합니다.

인수 설명
license_file 인스턴스의 라이선스 키가 들어 있는 파일의 경로입니다. 라이선스 키는 sudo gitlab-rails runner 'puts License.current.data'의 출력입니다. 활성화 코드는 라이선스 키가 아닙니다.
gitlab_version 데이터를 가져오는 GitLab 인스턴스의 버전이며, sudo gitlab-rails runner 'puts Gitlab::VERSION'의 출력입니다. 예: 19.4.0-ee.
dataset licenses 또는 malware_advisories.
output_dir 기록할 디렉터리입니다. 없으면 생성합니다. 변경되지 않은 스냅샷을 건너뛸 수 있도록 매 실행마다 같은 디렉터리를 사용합니다.
registry 하나 이상의 패키지 레지스트리입니다. 예: npm, pypi. 지원되는 모든 레지스트리는 all로 지정합니다. 관리자 설정에서 활성화한 유형의 레지스트리를 전달합니다. 레지스트리가 여러 개이면 공백으로 구분합니다. 스크립트는 다운로드하기 전에 지원되는 목록을 출력합니다.

스크립트는 패키지 유형이 아니라 레지스트리 식별자를 이름으로 하는 디렉터리를 패키지 레지스트리마다 하나씩 생성합니다. 예를 들면 다음과 같습니다.

licenses/
└── v3/
    └── npm/
        ├── full_dataset/
        │   ├── 00.tar.zst
        │   ├── 01.tar.zst
        │   ├── ...
        │   ├── 7f.tar.zst
        │   └── checkpoint.json
        └── deltas/
            ├── 1757520000.tar.zst
            └── 1757606400.tar.zst

각 스냅샷 아카이브의 이름은 16진수 샤드 식별자입니다. 레지스트리별 아카이브 수는 서비스가 정하며, 데이터가 없는 아카이브가 있을 수 있습니다. 각 델타 아카이브의 이름은 해당 기간이 끝나는 Unix 타임스탬프입니다. GitLab이 이 폴더에서 무엇을 읽는지는 v3 라이선스 데이터 형식 버전을 참고합니다.

레지스트리의 첫 실행은 스냅샷 전체를 다운로드하고, 이후 실행은 출력 디렉터리에 이미 있는 가장 최신 아카이브 이후에 서비스가 게시한 항목만 요청합니다.

Note

매 실행마다 같은 출력 디렉터리를 재사용합니다. 그렇지 않으면 스크립트가 이어서 받을 기준이 없어 스냅샷 전체를 다시 다운로드합니다.

레지스트리가 MAX_DELTAS개보다 많은 아카이브만큼 뒤처지면 스크립트는 스냅샷을 교체하고 그 옆의 deltas/ 폴더를 제거합니다. 서비스가 자체 일정에 따라 스냅샷을 다시 만들기 때문에 스냅샷이 그 옆의 델타보다 새로운 것은 정상이며, 스크립트는 그 스냅샷을 다시 다운로드하지 않고 계속 델타를 읽습니다. MAX_DELTAS의 기본값은 100이고, 서비스는 요청당 최대 20개의 델타 아카이브를 제공합니다. 0으로 설정하면 델타를 전혀 다운로드하지 않으며, 매 실행마다 스냅샷을 교체하던 이전 동작으로 돌아갑니다.

스크립트는 출력 디렉터리에 락도 생성하므로, 첫 번째 실행이 데이터를 기록하는 중에 다시 실행하면 디렉터리를 손상시키지 않고 종료합니다. 출력 디렉터리에 이미 있는 것보다 새로운 항목이 없으면 스크립트는 해당 레지스트리를 건너뛰므로, 일정에 따라 실행해도 안전합니다.

각 데이터 세트는 해당 절의 단계에 따라 제자리로 복사합니다. 라이선스 데이터와 악성코드 권고를 참고합니다.

GitLab v3 악성코드 권고 다운로드#

히스토리
  • GitLab 19.3에서 sync_malware_advisories 및 ingest_malware_advisories라는 플래그와 함께 도입되었습니다. 기본적으로 활성화되어 있습니다.
  • GitLab 19.5에서 정식 출시되었습니다. 기능 플래그 sync_malware_advisories와 ingest_malware_advisories가 제거되었습니다.
Note

이 기능은 Ultimate 외에도 의존성 방화벽 애드온을 사용하는 Premium 고객이 사용할 수 있습니다.

GitLab 악성코드 권고는 패키지 레지스트리에서 발견된 알려진 악성 패키지를 다룹니다. 이 권고는 Package Metadata Database 배포 서비스에서 제공하므로, 인터넷에 연결된 머신에서 다운로드해 오프라인 인스턴스로 복사합니다. v3 Package Metadata Database 데이터 다운로드에 설명된 공용 스크립트와 사전 요구 사항을 사용해 다운로드합니다.

./download_pmdb_data.sh ./Gitlab.gitlab-license 19.3.0-ee malware_advisories ./malware_advisories npm pypi

권고를 오프라인 인스턴스로 복사하려면 다음과 같이 합니다.

  1. 출력 디렉터리를 오프라인 인스턴스로 전송합니다.

  2. 오프라인 인스턴스에서 GitLab Rails 디렉터리의 루트를 찾은 다음, 권고를 제자리로 복사하고 권한을 업데이트합니다.

    export GITLAB_RAILS_ROOT_DIR="$(sudo gitlab-rails runner 'puts Rails.root.to_s')"
    echo $GITLAB_RAILS_ROOT_DIR
    sudo mkdir -p "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/malware_advisories"
    sudo rsync --recursive --delete ./malware_advisories/ "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/malware_advisories/"
    sudo chmod -R 755 "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/"
    

PackageMetadata::MalwareAdvisoriesSyncWorker cron 작업은 5분마다 실행되며 다음 실행에서 권고를 가져옵니다. GitLab Self-Managed에서는 인스턴스 간 부하를 분산하기 위해 이 작업에 최대 5분의 오프셋이 추가되므로, 파일을 복사한 뒤 최대 10분 후에 가져오기가 시작될 수 있습니다. 관리자 설정에서 활성화한 패키지 레지스트리 유형만 가져오므로, 인스턴스에는 가져오지 않는 레지스트리의 데이터가 있을 수 있습니다.

권고를 업데이트하려면 이 절차를 반복합니다. 이전 스냅샷의 아카이브가 새 아카이브와 함께 가져와지지 않도록 rsync --delete가 필요합니다. 매 실행마다 같은 출력 디렉터리를 재사용합니다. rsync --delete는 출력 디렉터리에 없는 레지스트리를 모두 제거하며, 스냅샷이 아직 게시되지 않아 건너뛴 레지스트리도 여기에 포함됩니다.

v3 라이선스 데이터 다운로드#

히스토리
  • GitLab 19.4에서 sync_v3_license_expressions라는 플래그와 함께 도입되었습니다. 기본적으로 활성화되어 있습니다.
Feature flag

이 기능의 사용 가능 여부는 기능 플래그로 제어됩니다. 자세한 내용은 히스토리를 참고합니다.

플래그를 끄면 인스턴스가 v2 레이아웃으로 되돌아가며 v3 체크포인트를 삭제합니다. 플래그를 다시 켜면 인스턴스는 마지막으로 동기화한 스냅샷부터 이어서 가져오지 않고 v3 데이터를 처음부터 다시 가져옵니다.

v3 라이선스 데이터 레이아웃은 단일 라이선스 식별자 대신 Software Package Data Exchange(SPDX) 라이선스 표현식을 담습니다. 예: MIT OR Apache-2.0.

Package Metadata Database 배포 서비스가 v3 라이선스 데이터를 배포하므로, 인터넷에 연결된 머신에서 다운로드해 오프라인 인스턴스로 복사합니다. 라이선스 버킷에 있는 모든 형식을 받지 않고, 활성화한 레지스트리에 필요한 데이터만 다운로드합니다. gsutil은 필요하지 않습니다. v3 Package Metadata Database 데이터 다운로드에 설명된 공용 스크립트와 사전 요구 사항을 사용해 다운로드합니다.

./download_pmdb_data.sh ./Gitlab.gitlab-license 19.4.0-ee licenses ./licenses npm pypi

vendor/package_metadata/licenses 아래에 v3 디렉터리가 있으면 인스턴스는 v2 디렉터리 대신 그 디렉터리에서 라이선스를 동기화합니다. 동기화 작업이 실행되기 전에 아래의 복사 단계를 완료합니다.

라이선스 데이터를 오프라인 인스턴스로 복사하려면 다음과 같이 합니다.

  1. 출력 디렉터리를 오프라인 인스턴스로 전송합니다.

  2. 오프라인 인스턴스에서 GitLab Rails 디렉터리의 루트를 찾은 다음, 라이선스 데이터를 제자리로 복사하고 권한을 업데이트합니다.

    export GITLAB_RAILS_ROOT_DIR="$(sudo gitlab-rails runner 'puts Rails.root.to_s')"
    echo $GITLAB_RAILS_ROOT_DIR
    export LICENSES_DIR="$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses"
    sudo rm -rf "$LICENSES_DIR/v3.old" "$LICENSES_DIR/v3.incoming"
    sudo mkdir -p "$LICENSES_DIR/v3" "$LICENSES_DIR/v3.incoming"
    sudo rsync --recursive ./licenses/v3/ "$LICENSES_DIR/v3.incoming/"
    sudo mv "$LICENSES_DIR/v3" "$LICENSES_DIR/v3.old" && \
      sudo mv "$LICENSES_DIR/v3.incoming" "$LICENSES_DIR/v3" && \
      sudo rm -rf "$LICENSES_DIR/v3.old"
    sudo chmod -R 755 "$GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/"
    

    이 명령은 v3 디렉터리에만 기록하므로 기존 v2 디렉터리는 사용되지 않은 채 디스크에 남습니다.

    인스턴스는 v3 디렉터리가 생기는 즉시 그 디렉터리에서 라이선스를 읽습니다. 동기화 작업은 5분마다 실행되므로 v3에 직접 복사하면, 동기화 작업이 아직 기록 중인 디렉터리를 읽을 수 있고 v3가 없거나 일부만 기록된 상태가 될 수 있습니다. 대신 v3.incoming에 복사한 다음 이름 변경으로 게시합니다.

PackageMetadata::LicensesSyncWorker cron 작업은 5분마다 실행되며 다음 실행에서 라이선스 데이터를 가져옵니다. 권고와 마찬가지로 이 작업은 Admin 영역에서 활성화한 패키지 레지스트리 유형만 가져오므로, 인스턴스에는 가져오지 않는 레지스트리의 데이터가 있을 수 있습니다.

라이선스 데이터를 업데이트하려면 이 절차를 반복합니다. 게시하면 v3 디렉터리 전체가 교체되므로, 이후 실행에서 빠진 레지스트리는 업데이트를 받지 못합니다. 해당 레지스트리에 대해 이미 가져온 라이선스 데이터는 데이터베이스에 남아 있으며, 다음 실행에 그 레지스트리를 포함하면 업데이트가 다시 시작됩니다.

v3 라이선스 데이터 형식 버전#

히스토리
  • GitLab 19.4에서 도입되었습니다.

v2와 v3는 라이선스 내보내기 파일의 형식 버전이며, 각각 별도 폴더에 게시되고 서로 다른 종류의 라이선스 데이터를 담습니다.

형식 버전 폴더 라이선스 데이터 읽는 버전
v2 licenses/v2/<registry>/ .ndjson 파일의 단일 SPDX 식별자. GitLab 19.3 이전 버전
v3 licenses/v3/<registry>/full_dataset/ 압축 아카이브의 SPDX 라이선스 표현식. GitLab 19.4 이후

형식 버전은 사용자가 아니라 GitLab이 선택합니다. 인스턴스에 두 폴더가 모두 있을 수 있지만, GitLab은 그중 하나만 읽습니다.

GitLab 19.4 이후에서는 sync_v3_license_expressions 플래그가 활성화되어 있고 vendor/package_metadata/licenses 아래에 v3/ 폴더가 있으면 GitLab이 v3를 읽습니다. 두 폴더가 모두 있으면 v3가 우선하고 v2/는 무시됩니다. v2를 읽게 하려면 플래그를 끕니다. 이전 버전은 항상 v2를 읽습니다.

v3 폴더 구조는 다음과 같습니다.

  • GitLab은 데이터를 v3/<registry> 아래에 구성하며, <registry>는 go, rubygem, packagist 같은 이름입니다.

  • 각 레지스트리 폴더에는 full_dataset/ 폴더가 있고, 선택적으로 deltas/ 폴더가 있습니다. full_dataset/은 샤드로 나뉜 전체 스냅샷이며, 스냅샷 타임스탬프가 담긴 checkpoint.json과 샤드 ID로 이름 붙인 샤드별 .tar.zst 아카이브(예: 00.tar.zst)로 구성됩니다. deltas/에는 증분 변경 사항이 들어 있으며, 각 아카이브의 이름은 해당 기간이 끝나는 Unix 타임스탬프입니다.

    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/checkpoint.json
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/00.tar.zst
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/01.tar.zst
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/full_dataset/...
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/deltas/1757520000.tar.zst
    $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/rubygem/deltas/1757606400.tar.zst
    
  • 레지스트리의 첫 동기화는 full_dataset/을 읽고 deltas/는 무시합니다. 델타는 전체 데이터 세트를 대신할 수 없기 때문입니다. 이후 동기화는 체크포인트보다 새로운 항목을 오래된 것부터 읽습니다.

    [!warning] 델타 아카이브의 이름은 해당 기간의 끝만 나타내므로, 누락이 있는 deltas/ 폴더도 완전한 폴더와 똑같아 보이며 GitLab은 오류를 보고하지 않습니다. 다운로드 스크립트가 생성한 deltas/ 폴더 전체를 복사하고, 그 안의 일부 아카이브만 골라 복사하지 않습니다.

  • 동기화 작업은 정해진 시간이 지나면 중단되므로 큰 레지스트리는 여러 번 실행해야 합니다. 스냅샷은 첫 번째 동기화뿐 아니라 모든 동기화에서 샤드 단위로 재개되므로, 중단된 실행은 최대 샤드 하나만 다시 읽습니다. 델타 아카이브는 통째로 재개되므로, 중단된 아카이브는 다음 실행에서 처음부터 다시 읽습니다. 큰 델타를 스냅샷처럼 샤드로 나누는 작업은 이슈 608196에서 추적합니다.

Helm 차트로 설치한 인스턴스#

이 절차는 Linux 패키지 설치의 Rails 디렉터리에 기록합니다. 동기화 작업은 Sidekiq에서 실행됩니다. GitLab Helm 차트를 사용하는 설치에서는 같은 파일이 Sidekiq 파드가 /srv/gitlab/vendor/package_metadata에서 읽는 볼륨에 있어야 합니다. toolbox 파드에만 마운트한 볼륨으로는 충분하지 않습니다. 동기화는 여전히 Sidekiq에서 실행되고, 여전히 디렉터리를 찾지 못하며, 실행할 때마다 네트워크 경로를 시도합니다.

차트 values의 gitlab.sidekiq 아래에 볼륨을 선언합니다.

gitlab:
  sidekiq:
    extraVolumes: |
      - name: package-metadata
        persistentVolumeClaim:
          claimName: gitlab-package-metadata
          readOnly: true
    extraVolumeMounts: |
      - name: package-metadata
        mountPath: /srv/gitlab/vendor/package_metadata
        readOnly: true

다른 설정은 필요하지 않습니다. GitLab은 디렉터리의 존재 여부로 오프라인 경로를 선택합니다. 변경 사항은 재시작이나 Rake 작업 없이 다음 동기화 실행부터 적용됩니다.

모든 Sidekiq 파드가 클레임을 마운트하므로 ReadOnlyMany처럼 이를 허용하는 액세스 모드를 볼륨에 지정합니다. GitLab은 이 파일을 읽기만 합니다.

values에서 다음 두 가지 실수에 주의합니다.

  • extraVolumes와 extraVolumeMounts는 YAML 목록이 아니라 문자열로 작성합니다. 목록으로 작성하면 템플릿 이름이 표시되는 타입 오류와 함께 렌더링에 실패합니다. 키를 잘못된 수준에 둔 values 파일은 오히려 아무 표시 없이 실패합니다. Helm은 인식하지 못하는 키를 무시하고, Deployment는 마운트 없이 렌더링되며, 명령은 성공합니다. 렌더링이 성공했다고 해서 마운트가 반영되었다는 증거는 아닙니다. 마운트가 Deployment에 반영되었는지 확인합니다.

    kubectl get deployment -l app=sidekiq -o yaml | grep -c 'vendor/package_metadata'
    

    차트는 이 문자열을 Helm 템플릿 엔진에 통과시키므로, 볼륨 이름이나 마운트 경로에 {{가 있으면 이스케이프합니다.

  • 마운트는 kubectl set env나 kubectl patch가 아니라 values에 넣어야 합니다. 다음 helm upgrade가 그 설정을 덮어씁니다. 저장된 구성에서 조정하는 배포에서는 다음 조정 때 인스턴스가 네트워크 경로로 돌아갑니다. 오프라인 환경에서는 취약점 데이터가 전혀 없다는 뜻입니다.

가져오기 진행 상황을 확인하려면 문제 해결에서 설명하는 대로 Webservice 파드가 아니라 Sidekiq 파드에서 기록된 시퀀스와 로그를 읽습니다.

문제 해결#

누락된 데이터베이스 데이터#

의존성 목록, 취약점 보고서, 머지 리퀘스트 페이지에 라이선스 또는 권고 데이터가 없다면 데이터베이스가 내보내기 데이터와 동기화되지 않았을 수 있습니다.

활성화된 패키지 레지스트리 유형 확인#

package_metadata 동기화는 cron 작업(권고 동기화 및 라이선스 동기화)으로 실행됩니다. 관리자 설정에서 활성화한 패키지 레지스트리 유형만 가져옵니다.

예를 들어 maven을 선택하고 golang을 선택하지 않았다면 Maven의 권고와 라이선스 정보만 표시됩니다.

올바른 파일 구조 확인#

vendor/package_metadata의 파일 구조는 앞에서 활성화한 패키지 레지스트리 유형과 일치해야 합니다. 예를 들어 maven 라이선스 또는 권고 데이터를 동기화하려면 Rails 디렉터리 아래의 패키지 메타데이터 디렉터리가 다음 구조여야 하며, 여기서 $GITLAB_RAILS_ROOT_DIR는 gitlab-rails runner 'puts Rails.root.to_s' 명령의 출력과 일치해야 합니다.

  • v2 라이선스(GitLab 19.3 이전 버전): $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v2/maven/**/*.ndjson.
  • v3 라이선스(GitLab 19.4 이후): $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/licenses/v3/maven/full_dataset/*. v3 라이선스 데이터 형식 버전을 참고합니다.
  • v2 권고: $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/advisories/v2/maven/**/*.ndjson.

CVE 보강 데이터는 패키지 레지스트리 유형별로 나뉘지 않습니다. GitLab 19.5 이후에서는 GitLab이 버킷을 복사된 그대로 만든 사본을 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2/cve_enrichment/*/*.ndjson에서 읽습니다. 그 cve_enrichment 디렉터리가 있으면 GitLab은 그 디렉터리만 읽습니다. GitLab 19.4 이전 버전이거나 그 디렉터리가 없는 경우, CVE 보강 파일은 버전 디렉터리 아래 정확히 두 디렉터리 수준에 있어야 합니다.

  • CVE 보강: $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/cve_enrichment/v2/*/*.ndjson.

GitLab 19.4 이전 버전에서 CVE 보강 버킷을 그대로 복사하면 파일이 필요한 것보다 한 수준 깊은 곳에 놓이며, GitLab은 그 파일을 하나도 읽지 못합니다. 이를 바로잡으려면 다운로드 절차의 CVE 보강 단계를 참고합니다.

Rails 콘솔에서 GitLab이 파일 경로를 인식하는지 확인할 수 있습니다.

  • 라이선스: sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::LICENSES_PATH)"
  • 권고: sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::ADVISORIES_PATH)"
  • CVE 보강: sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::CVE_ENRICHMENT_PATH)"

위 명령이 false를 반환하면 GitLab이 예상 패키지 경로를 찾지 못한 것입니다. 경로의 모든 폴더와 파일에는 755 권한이 있어야 합니다. 권한을 업데이트하려면 다음을 실행합니다.

sudo chmod -R 755 $GITLAB_RAILS_ROOT_DIR/vendor/package_metadata/

데이터 확인#

동기화 작업이 성공적으로 실행되면 데이터베이스의 pm_ 테이블에 데이터가 채워져 있어야 합니다.

Rails 콘솔에서 공급업체별 패키지 수를 확인해 이를 검증할 수 있습니다. 예를 들어 Maven 라이선스 및 권고 데이터가 로드되었는지 확인하려면 다음을 실행합니다.

  • 라이선스: sudo gitlab-rails runner "puts \"Package model has #{PackageMetadata::Package.where(purl_type: 'maven').size} packages\""
  • 권고: sudo gitlab-rails runner "puts \"Advisory model has #{PackageMetadata::AffectedPackage.where(purl_type: 'maven').size} packages\""

또한 동기화 중인 패키지 레지스트리에는 체크포인트 데이터가 있어야 합니다. 예를 들어 Maven은 동기화 실행이 성공한 뒤 체크포인트가 생성되어 있어야 합니다.

  • 라이선스: sudo gitlab-rails runner "puts \"maven data has been synced up to #{PackageMetadata::Checkpoint.where(data_type: 'licenses', purl_type: 'maven')}\""
  • 권고: sudo gitlab-rails runner "puts \"maven data has been synced up to #{PackageMetadata::Checkpoint.where(data_type: 'advisories', purl_type: 'maven')}\""

로그#

application_json.log 파일은 동기화 작업이 실행되었는지, 그리고 오류 없이 실행되었는지 확인하는 데 도움이 됩니다. 동기화와 관련된 이벤트는 심각도가 DEBUG이고 클래스는 PackageMetadata::SyncService입니다. 예시: {"severity":"DEBUG","time":"2026-01-07T02:15:49.618Z","meta.caller_id":"PackageMetadata::AdvisoriesSyncWorker","correlation_id":"43008e30dd708eadbe1ab16ad7fa953f","meta.root_caller_id":"Cronjob","meta.feature_category":"software_composition_analysis","meta.client_id":"ip/","class":"PackageMetadata::SyncService","message":"Evaluating data for advisories:offline//opt/gitlab/embedded/service/gitlab-rails/vendor/package_metadata/advisories/v2/maven/1761761049/0.ndjson"}

sidekiq 로그에는 동기화 작업 중 발생한 오류가 표시됩니다. 동기화에 대해 기록된 이벤트에는 다음 관련 클래스가 언급됩니다.

  • 라이선스: PackageMetadata::LicensesSyncWorker
  • 권고: PackageMetadata::AdvisoriesSyncWorker

누락된 v3 라이선스 데이터#

인스턴스로 복사한 뒤에도 라이선스 데이터가 없다면, 동기화 작업이 디렉터리를 찾지 못했거나 가져올 새 항목을 찾지 못한 것입니다. 두 경우 모두 아무 표시가 없으므로 오류를 찾는 대신 다음 점검을 차례로 진행합니다.

시작하기 전에 다음을 확인합니다.

v3 라이선스 데이터 확인#

표현식은 라이선스 레코드에 저장됩니다. 동기화된 v3 스냅샷을 확인하려면 표현식이 있는 라이선스가 하나 이상 있는지 확인합니다. Rails 콘솔에서 확인할 수 있습니다.

  • sudo gitlab-rails runner "puts \"#{PackageMetadata::License.where.not(spdx_expression: nil).count} licenses have an expression\""

패키지와 체크포인트는 있는데 표현식이 있는 라이선스가 없다면, 인스턴스가 v3가 아니라 v2 레이아웃을 동기화한 것입니다. 디스크의 데이터가 v3/ 아래에 있는지 확인합니다.

스냅샷은 기록된 체크포인트보다 새로운 경우에만 가져오므로, 같은 스냅샷을 두 번째로 복사해도 아무 효과가 없습니다. 더 새로운 라이선스 데이터를 받으려면 다운로드 스크립트를 다시 실행합니다.

누락된 악성코드 권고 데이터#

인스턴스로 복사한 뒤에도 악성코드 권고가 없다면, 동기화 작업이 디렉터리를 찾지 못했거나 가져올 새 항목을 찾지 못한 것입니다. 두 경우 모두 아무 표시가 없으므로 오류를 찾지 말고 다음 점검을 차례로 진행합니다.

악성코드 권고에 대해 활성화된 패키지 레지스트리 유형 확인#

관리자 설정에서 활성화한 패키지 레지스트리 유형만 가져옵니다. 복사했지만 활성화하지 않은 레지스트리는 읽지 않으며, 아무 로그도 남지 않습니다.

GitLab이 오프라인 디렉터리를 감지하는지 확인#

GitLab은 vendor 디렉터리가 있으면 디스크에서 악성코드 권고를 읽고, 없으면 배포 서비스에서 읽습니다. Rails 콘솔에서 GitLab이 파일 경로를 인식하는지 확인할 수 있습니다.

  • sudo gitlab-rails runner "puts File.exist?(PackageMetadata::SyncConfiguration::Location::MALWARE_ADVISORIES_PATH)"

명령이 false를 반환하면 GitLab이 예상 경로를 찾지 못한 것입니다. 경로의 모든 폴더와 파일에는 755 권한이 있어야 합니다. Kubernetes 설치에서는 Sidekiq과 같은 볼륨을 마운트한 파드에서 명령을 실행합니다. 그렇지 않으면 결과가 동기화 작업이 읽는 내용을 반영하지 않습니다.

악성코드 권고 파일 구조 확인#

디렉터리 이름은 패키지 유형이 아니라 패키지 레지스트리 식별자를 따릅니다. 이 식별자는 항상 같지는 않습니다. gem 권고는 rubygem 아래에, golang은 go 아래에, composer는 packagist 아래에 저장됩니다. 패키지 유형으로 이름 붙인 디렉터리는 무시되며 오류도 기록되지 않습니다.

또한 각 full_dataset 디렉터리에는 checkpoint.json이 있어야 합니다. GitLab은 체크포인트가 없는 디렉터리를 무시하며, 이는 중단된 다운로드가 가져와지는 것을 막습니다.

악성코드 권고 데이터 확인#

동기화 작업이 성공적으로 실행되면 악성코드 권고가 채워져 있어야 합니다. Rails 콘솔에서 권고 수를 세어 확인할 수 있습니다.

  • sudo gitlab-rails runner "puts \"Malware advisory model has #{PackageMetadata::MalwareAdvisory.count} advisories\""

복사한 패키지 레지스트리에도 체크포인트 데이터가 있어야 합니다.

  • sudo gitlab-rails runner "puts PackageMetadata::Checkpoint.where(data_type: 'malware_advisories').pluck(:purl_type, :sequence).to_h"

스냅샷은 기록된 체크포인트보다 새로운 경우에만 가져오므로, 같은 스냅샷을 두 번째로 복사해도 아무 효과가 없습니다.

악성코드 권고 로그#

악성코드 권고 동기화와 관련된 이벤트는 application_json.log에 INFO 심각도로 기록됩니다. 클래스는 동기화 자체에는 PackageMetadata::MalwareAdvisorySyncService, 가져오기에는 PackageMetadata::MalwareAdvisoryIngestionService입니다. 동기화는 Sidekiq에서 실행되므로 Kubernetes 설치에서는 이 이벤트가 Webservice 파드가 아니라 Sidekiq 파드의 subcomponent="application_json" 키 아래에 있습니다.

sidekiq 로그에는 발생한 오류가 표시되며, PackageMetadata::MalwareAdvisoriesSyncWorker 클래스에 대해 기록됩니다.

각 레지스트리는 실행마다 started 이벤트와 completed 이벤트를 기록합니다. files_ingested: 0이 포함된 completed 이벤트는 해당 실행에서 가져올 항목을 찾지 못했다는 뜻입니다. 해당 이벤트의 storage_type 필드로 어느 경우인지 알 수 있습니다. offline은 GitLab이 vendor 디렉터리를 읽었다는 뜻이고, pds는 디렉터리를 찾지 못해 배포 서비스를 시도했다는 뜻입니다. Kubernetes에서는 이 필드가 동기화를 실행하는 프로세스에서 나오므로 앞의 File.exist? 확인보다 더 신뢰할 수 있습니다.

PackageMetadata::MalwareAdvisorySyncService 이벤트가 전혀 나타나지 않으면 심각도가 DEBUG인 PackageMetadata::MalwareAdvisoriesSyncWorker 이벤트를 찾습니다. 이 이벤트에는 dependency_scanning 라이선스와 의존성 방화벽 중 어느 것도 사용할 수 없는 경우처럼 실행이 시작되지 않은 이유가 나와 있습니다.

큰 레지스트리의 첫 가져오기는 몇 분 동안 실행될 수 있으며, 그동안 cron은 5분마다 다시 실행됩니다. 겹친 실행은 각각 application_json.log에 ERROR 심각도로 Cannot obtain an exclusive lease. There must be another instance already in execution.를 기록한 뒤 종료합니다. 이 리스가 두 동기화가 동시에 실행되는 것을 막으므로, 긴 가져오기 중에는 이 이벤트가 예상되며 동기화가 실패했다는 뜻이 아닙니다.