Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
shellcheck tests/test-scan.sh || true
echo ""
echo "Checking new test/helper scripts..."
shellcheck tests/test-postprocess.sh tests/test-vendored-adversarial.sh tests/test-web-ui.sh tests/check-examples-sync.sh examples/test-all.sh || true
shellcheck tests/test-postprocess.sh tests/test-snapshot.sh tests/test-vendored-adversarial.sh tests/test-web-ui.sh tests/check-examples-sync.sh examples/test-all.sh || true
echo ""
echo "✅ Shellcheck completed (warnings are informational)"

Expand Down Expand Up @@ -134,6 +134,9 @@ jobs:
- name: Post-process unit tests
run: bash tests/test-postprocess.sh

- name: Output regression snapshots
run: bash tests/test-snapshot.sh

- name: Vendored-OSS adversarial tests
run: bash tests/test-vendored-adversarial.sh

Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,10 @@ jobs:
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Install syft (pinned release tarball)
if: steps.flags.outputs.push == 'true'
env:
SYFT_VERSION: v1.18.1 # keep in sync with docker/Dockerfile
run: |
# Single source of truth: read the pinned version from docker/Dockerfile
# (no separate literal to keep in sync).
SYFT_VERSION=$(grep -oP 'ARG SYFT_VERSION=\K\S+' docker/Dockerfile)
ver="${SYFT_VERSION#v}"
curl -sSfL "https://github.com/anchore/syft/releases/download/${SYFT_VERSION}/syft_${ver}_linux_amd64.tar.gz" -o /tmp/syft.tgz
sudo tar -C /usr/local/bin -xzf /tmp/syft.tgz syft
Expand Down Expand Up @@ -135,12 +136,12 @@ jobs:
continue-on-error: true

# Supply-chain note: use a pinned Trivy CLI (not the trivy-action that had
# a supply-chain incident). Keep TRIVY_VERSION in sync with docker/Dockerfile.
# a supply-chain incident). The version is read from docker/Dockerfile so
# there is a single source of truth.
- name: Install Trivy (pinned release tarball)
if: github.event_name != 'pull_request'
env:
TRIVY_VERSION: v0.70.0
run: |
TRIVY_VERSION=$(grep -oP 'ARG TRIVY_VERSION=\K\S+' docker/Dockerfile)
ver="${TRIVY_VERSION#v}"
curl -sSfL "https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${ver}_Linux-64bit.tar.gz" -o /tmp/trivy.tgz
sudo tar -C /usr/local/bin -xzf /tmp/trivy.tgz trivy
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/renovate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Self-hosted Renovate, scoped to the customManagers in renovate.json (the
# ARG/shell-pinned scanner tools cdxgen/syft/trivy/scanoss/... that Dependabot
# cannot see). Native managers are disabled in renovate.json, so this does NOT
# overlap with the existing Dependabot config (npm, GitHub Actions, base images).
#
# Prerequisite: a repo/org secret RENOVATE_TOKEN — a PAT (classic: repo + workflow,
# or a fine-grained token with contents+pull-requests write) used to open bump PRs.
# Without it the run no-ops. See docs/internal/dependency-upgrade-policy.md.
name: Renovate

on:
schedule:
- cron: "0 3 * * 1" # weekly Monday 03:00 UTC, ahead of examples.yml / upstream-compat.yml
workflow_dispatch:
inputs:
dryRun:
description: "Run Renovate in dry-run mode (no PRs)"
type: boolean
default: false

permissions:
contents: read

concurrency:
group: renovate
cancel-in-progress: false

jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Run Renovate
uses: renovatebot/github-action@6d859fc95779be83a0335ca704879b47e5d79641 # v46.1.16
with:
configurationFile: renovate.json
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_DRY_RUN: ${{ inputs.dryRun && 'full' || null }}
LOG_LEVEL: info
109 changes: 109 additions & 0 deletions .github/workflows/upstream-compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Upstream compatibility early-warning. examples.yml runs with PINNED tool
# versions, so it never tells us that the NEXT version will break. This job pulls
# the latest cdxgen images at runtime, scans representative examples, and checks
# the output is still well-formed CycloneDX with resolvable components. If a
# future cdxgen breaks generation, this fires BEFORE we attempt the bump and
# opens a tracking issue.
#
# Scope: cdxgen (pulled at runtime via CDXGEN_TAG / CDXGEN_ALLINONE, so it can
# drift even on the moving v12 tag). syft/trivy are baked into the image by ARG;
# their drift surfaces on the Renovate bump PR, which runs the full CI (example
# scans + output snapshots). See docs/internal/dependency-upgrade-policy.md.
name: Upstream compatibility

on:
schedule:
- cron: "0 6 * * 1" # weekly Monday 06:00 UTC, after Renovate / examples
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
compat:
name: Latest cdxgen vs examples
runs-on: ubuntu-latest
env:
# Preview the newest cdxgen instead of the pinned tag.
CDXGEN_TAG: latest
CDXGEN_ALLINONE: ghcr.io/cyclonedx/cdxgen:latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Build SBOM Scanner image
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: ./docker
load: true
tags: sbom-scanner:test
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Scan examples with the latest cdxgen
id: scan
env:
SBOM_SCANNER_IMAGE: sbom-scanner:test
run: |
set -u
# Examples that resolve real transitive dependencies (so a collapse to
# zero components is a meaningful regression signal).
EXAMPLES="python nodejs java-maven go"
report=""
failed=0
for ex in $EXAMPLES; do
echo "::group::$ex"
( cd "examples/$ex" && ../../scripts/scan-sbom.sh \
--project "${ex}-compat" --version "1.0.0" --generate-only ) || {
report="${report}- ❌ ${ex}: scan-sbom.sh failed (latest cdxgen)\n"
failed=1; echo "::endgroup::"; continue; }
sbom=$(find "examples/$ex" -maxdepth 1 -name '*_bom.json' 2>/dev/null | head -1)
if [ -z "$sbom" ] || ! jq empty "$sbom" 2>/dev/null; then
report="${report}- ❌ ${ex}: no valid SBOM produced\n"; failed=1; echo "::endgroup::"; continue
fi
if ! jq -e '.bomFormat=="CycloneDX"' "$sbom" >/dev/null; then
report="${report}- ❌ ${ex}: output is not CycloneDX\n"; failed=1; echo "::endgroup::"; continue
fi
spec=$(jq -r '.specVersion' "$sbom")
count=$(jq '.components | length' "$sbom")
if [ "$count" -eq 0 ]; then
report="${report}- ❌ ${ex}: 0 components (dependency resolution broke)\n"; failed=1
else
report="${report}- ✅ ${ex}: ${count} components, specVersion ${spec}\n"
fi
echo "::endgroup::"
done
{
echo "## Latest-cdxgen compatibility"
echo ""
printf '%b' "$report"
} >> "$GITHUB_STEP_SUMMARY"
{
echo "report<<EOF"
printf '%b' "$report"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "failed=$failed" >> "$GITHUB_OUTPUT"

- name: Open or update tracking issue on failure
if: steps.scan.outputs.failed == '1'
env:
GH_TOKEN: ${{ github.token }}
REPORT: ${{ steps.scan.outputs.report }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
title="Upstream compatibility: latest cdxgen breaks example scans"
body=$(printf '%b\n\nRun: %s\n\nThe pinned version still works; this is an early warning that bumping cdxgen will need attention. See docs/internal/dependency-upgrade-policy.md.' "$REPORT" "$RUN_URL")
existing=$(gh issue list --label upstream-compat --state open \
--search "$title in:title" --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "$body"
else
gh label create upstream-compat --color FBCA04 --description "Upstream tool compatibility early warning" 2>/dev/null || true
gh issue create --title "$title" --label upstream-compat --body "$body"
fi
14 changes: 13 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,38 +34,50 @@

ENV DEBIAN_FRONTEND=noninteractive

# Pinned tool versions (supply-chain hygiene)
# Pinned tool versions (supply-chain hygiene). The `# renovate:` comments let
# Renovate (renovate.json customManagers) track each upstream and open bump PRs;
# Dependabot cannot see these ARG-pinned, curl/pip-installed tools. See
# docs/internal/dependency-upgrade-policy.md.
# renovate: datasource=github-releases depName=anchore/syft
ARG SYFT_VERSION=v1.18.1
# renovate: datasource=github-releases depName=aquasecurity/trivy
ARG TRIVY_VERSION=v0.70.0
# renovate: datasource=github-releases depName=sigstore/cosign
ARG COSIGN_VERSION=v2.4.1
# renovate: datasource=pypi depName=scancode-toolkit
ARG SCANCODE_VERSION=32.3.0
ARG SBOM_DEEP_LICENSE=false
# Vendored-OSS identification (opt-in: --build-arg SBOM_SCANOSS=true). scanoss.py
# is MIT, so unlike the firmware GPL tools it can live in the base image; kept
# opt-in only to keep the default image lean and free of outbound API calls.
ARG SBOM_SCANOSS=false
# renovate: datasource=pypi depName=scanoss
ARG SCANOSS_VERSION=1.25.2
ARG TARGETARCH=amd64
# docker CLI (client only) — lets the web UI source scan launch cdxgen language
# images as sibling containers via the mounted host socket (transitive deps).
# renovate: datasource=github-releases depName=docker/cli extractVersion=^v(?<version>.+)$
ARG DOCKER_CLI_VERSION=27.5.1

# Firmware analysis (opt-in image `sbom-scanner-firmware`). GPL tools are isolated
# here so the base image stays permissive-only; see THIRD_PARTY_LICENSES.md.
ARG SBOM_FIRMWARE=false
# renovate: datasource=pypi depName=unblob
ARG UNBLOB_VERSION=26.3.30
# renovate: datasource=pypi depName=cve-bin-tool
ARG CVE_BIN_TOOL_VERSION=3.4
# renovate: datasource=pypi depName=ubi_reader
ARG UBI_READER_VERSION=0.8.13

# jq (helper scripts), curl/tar (installers), file (binary mode),
# git (web UI 'GitHub URL' ingestion: server.py clones into a temp source tree).
# python3 + pip are already in the python:3.12-slim base (web UI + scancode).
RUN apt-get update && apt-get install -y --no-install-recommends \

Check failure on line 75 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Scripts

DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
jq curl wget ca-certificates bash tar file git \
&& rm -rf /var/lib/apt/lists/*

# syft — image/binary/RootFS scanning (pinned)
RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \

Check failure on line 80 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Scripts

DL4006 warning: Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check
| sh -s -- -b /usr/local/bin "${SYFT_VERSION}" \
&& syft version

Expand Down Expand Up @@ -130,7 +142,7 @@
# NOTE: the PyPI `binwalk` 2.x dist is broken (no binwalk.core), so it is NOT
# installed; unsquashfs covers the common squashfs case. vendor-modified
# (non-standard) squashfs still needs sasquatch added on top of this.
RUN if [ "$SBOM_FIRMWARE" = "true" ]; then \

Check failure on line 145 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Scripts

DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
apt-get update && apt-get install -y --no-install-recommends \
squashfs-tools e2fsprogs p7zip-full unar cpio cabextract \
lzop zstd lz4 liblzo2-2 zlib1g \
Expand Down
2 changes: 2 additions & 0 deletions docker/lib/source-detect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
# Defaults use ${VAR:-default} so a caller that already exported these (the CLI)
# keeps its values; a caller that did not (the UI) gets the defaults.

# renovate: datasource=docker depName=ghcr.io/cyclonedx/cdxgen
CDXGEN_TAG="${CDXGEN_TAG:-v12}" # cdxgen language image tag
# renovate: datasource=docker depName=ghcr.io/cyclonedx/cdxgen
CDXGEN_ALLINONE="${CDXGEN_ALLINONE:-ghcr.io/cyclonedx/cdxgen:v12.5.0}"
ANDROID_IMAGE_PREFIX="${ANDROID_IMAGE_PREFIX:-ghcr.io/sktelecom/bomlens-android-sdk}" # legacy alias: sbom-scanner-android-sdk (same digest)
ANDROID_API_DEFAULT="${ANDROID_API_DEFAULT:-34}"
Expand Down
1 change: 1 addition & 0 deletions docs/internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
| [문서 사용성 검토 보고서](docs-usability-review.md) | 신규 사용자 관점의 README와 가이드 검토, 우선순위 개선안 |
| [외부 등록 채널](seo-external-listings.md) | 검색·AI 노출용 큐레이션 목록과 레지스트리 제출 자료 |
| [배포 절차](release-guide.md) | 태그 기반 릴리스 체크리스트와 실행 절차 |
| [도구 버전 업그레이드 안전장치](dependency-upgrade-policy.md) | 외부 도구 신규 버전 도입을 지키는 4계층(감지·스냅샷·정본·호환성 점검) |

> 사용자용 경량 가이드는 상위 [docs/](../)에 있습니다. 펌웨어는 [펌웨어 분석 가이드](../guides/firmware.ko.md), 공급사 SBOM 검증은 [공급사 SBOM 검증 가이드](../guides/supplier-sbom.ko.md)를 참고하세요.
65 changes: 65 additions & 0 deletions docs/internal/dependency-upgrade-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 도구 버전 업그레이드 안전장치 (메인테이너용)

이 문서는 BomLens가 의존하는 외부 오픈소스 도구(cdxgen·trivy·syft·scanoss·unblob·cve-bin-tool·scancode 등)의 신규 버전을 안전하게 도입하기 위한 체계를 정리한다. 도구를 사용하려는 분은 이 폴더가 아니라 [docs/](../) 상위의 사용자 가이드를 보세요.

## 왜 필요한가

BomLens는 여러 도구의 조합이고, 그 버전을 따라가는 것이 기본 운영이다. 그런데 도구 버전은 `docker/Dockerfile`의 ARG와 `docker/lib/source-detect.sh`의 셸 변수로 핀돼 있어 Dependabot이 인식하지 못한다(Dependabot은 npm·GitHub Actions·base Docker 이미지만 본다). 또한 기존 테스트는 출력이 CycloneDX인지 정도만 확인해, 도구를 올렸을 때 specVersion·컴포넌트·필드가 달라져도 조용히 통과한다. 이 두 빈틈을 메우기 위해 네 계층을 둔다.

## 네 계층

| 계층 | 무엇 | 어디 |
|---|---|---|
| 1. 신규 버전 감지 | Renovate가 도구 버전을 추적해 bump PR을 연다 | `renovate.json`, `.github/workflows/renovate.yml` |
| 2. 출력 회귀 스냅샷 | 후처리 출력을 golden과 비교해 변화가 diff로 드러난다 | `tests/test-snapshot.sh`, `tests/lib/snapshot-normalize.jq`, `tests/snapshots/` |
| 3. 단일 버전 소스 + 절차 | 버전 정본 일원화와 사람 검증 체크리스트 | `docker/Dockerfile`(ARG 정본), [배포 절차](release-guide.md) |
| 4. 주기적 호환성 점검 | 최신 cdxgen으로 예제를 미리 돌려 깨짐을 사전 경고 | `.github/workflows/upstream-compat.yml` |

### 계층 1 — 신규 버전 감지 (Renovate)

Dependabot이 못 보는 ARG·셸 변수 핀을 Renovate의 customManager 정규식으로 추적한다. `docker/Dockerfile`의 각 도구 ARG 위에 `# renovate: datasource=... depName=...` 주석을 달아 두면, Renovate가 업스트림 릴리스와 비교해 bump PR을 연다. cdxgen 이미지 태그(`source-detect.sh`의 `CDXGEN_TAG`·`CDXGEN_ALLINONE`)도 같은 방식으로 추적한다.

기존 Dependabot 설정과 겹치지 않도록 Renovate는 `enabledManagers: ["custom.regex"]`로 customManager만 켠다. npm·GitHub Actions·base Docker 이미지는 그대로 Dependabot이 담당한다.

실행에는 저장소/조직 시크릿 `RENOVATE_TOKEN`(PR 생성용 PAT, classic은 repo+workflow, fine-grained는 contents+pull-requests write)이 필요하다. 없으면 워크플로는 아무 일도 하지 않는다.

### 계층 2 — 출력 회귀 스냅샷

`tests/test-snapshot.sh`는 고정 입력 픽스처를 후처리 스크립트로 돌린 결과에서 휘발성 필드(타임스탬프·serialNumber·도구 버전)를 `tests/lib/snapshot-normalize.jq`로 걷어내고, `tests/snapshots/`의 golden과 비교한다. specVersion·컴포넌트·라이선스·cpe 같은 의미 있는 변화는 모두 diff로 드러난다. 매 PR에서 `ci.yml`의 후처리 잡이 실행한다(Docker 불필요).

의도된 변화로 출력이 바뀌면 golden을 다시 떠서 커밋한다.

```bash
UPDATE_SNAPSHOTS=1 bash tests/test-snapshot.sh
```

이 스냅샷은 우리 jq 파이프라인의 회귀를 잡는다. 도구 버전 자체가 출력을 바꾸는 드리프트는 계층 4가 같은 정규화 필터를 재사용해 잡는다.

### 계층 3 — 단일 버전 소스 + 절차

버전 정본은 `docker/Dockerfile`의 ARG다. `docker-publish.yml`은 이 값을 grep으로 읽어 쓰므로 워크플로에 같은 버전을 따로 적어 동기화할 필요가 없다.

도구를 올릴 때 따르는 사람 검증 절차는 [배포 절차](release-guide.md)의 "도구 버전 업그레이드"를 따른다.

### 계층 4 — 주기적 호환성 점검

`examples.yml`은 핀된 버전으로 돌아 다음 버전이 깨질지는 알려주지 못한다. `upstream-compat.yml`은 주간으로 최신 cdxgen을 당겨 대표 예제(python·nodejs·java-maven·go)를 스캔하고, 출력이 여전히 정상 CycloneDX이며 컴포넌트가 해소되는지 확인한다. 깨지면 `upstream-compat` 라벨로 추적 이슈를 자동으로 연다. cdxgen은 런타임에 당겨오므로 이동 태그(v12)에서도 드리프트가 생길 수 있어 이 점검이 의미가 있다. syft·trivy는 이미지에 ARG로 박히므로, 이들의 드리프트는 Renovate bump PR이 전체 CI(예제 스캔·스냅샷)를 돌리며 드러난다.

## 업그레이드 흐름

도구 신규 버전이 나오면 다음 순서로 처리한다.

1. Renovate가 bump PR을 연다(계층 1). 메이저는 `major-upgrade` 라벨로 분리된다.
2. PR에서 기존 CI가 돈다. 계층 2 스냅샷이 출력 변화를 diff로 보여 준다. 변화가 없으면 안전한 bump다.
3. 출력이 의도대로 바뀌었으면 golden을 갱신해 같은 PR에 커밋한다(`UPDATE_SNAPSHOTS=1`).
4. cdxgen·trivy 메이저 등 영향이 큰 bump는 [배포 절차](release-guide.md)의 도구 업그레이드 체크리스트를 따른다.
5. 계층 4가 이미 이슈로 경고한 깨짐이라면, 그 이슈에 bump PR을 연결해 닫는다.

## Dependabot과의 분담

| 대상 | 담당 |
|---|---|
| npm(electron, web UI), GitHub Actions, base Docker 이미지 | Dependabot(`.github/dependabot.yml`) |
| ARG·셸 변수로 핀된 스캐너 도구, cdxgen 이미지 태그 | Renovate customManager(`renovate.json`) |

두 도구는 대상이 겹치지 않으므로 같은 의존성에 중복 PR을 내지 않는다.
12 changes: 12 additions & 0 deletions docs/internal/release-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ git push origin vX.Y.Z
- 태그 릴리스는 main push보다 무겁고 느리다. Android SDK 이미지 6종(멀티아치)과 firmware
이미지를 추가로 빌드하기 때문이다. 전체가 그린이 될 때까지 기다려 확인한다.

## 도구 버전 업그레이드

외부 도구(cdxgen·trivy·syft·scanoss·unblob·cve-bin-tool·scancode 등)를 올릴 때 따르는 절차다. 전체 안전장치 설계는 [도구 버전 업그레이드 안전장치](dependency-upgrade-policy.md)를 보세요.

버전 정본은 `docker/Dockerfile`의 ARG와 `docker/lib/source-detect.sh`의 cdxgen 태그다. Renovate가 이들을 추적해 bump PR을 연다. PR이 왔을 때 확인한다.

1. **스냅샷 diff를 본다.** PR의 CI에서 `tests/test-snapshot.sh`가 출력 변화를 diff로 보여 준다. 변화가 없으면 안전한 bump다.
2. **출력이 의도대로 바뀌었으면 golden을 갱신한다.** `UPDATE_SNAPSHOTS=1 bash tests/test-snapshot.sh`로 다시 떠서 같은 PR에 커밋하고, diff가 합당한지 검토한다.
3. **cdxgen 메이저(예: v12에서 v13으로)**는 영향이 크다. 대표 예제 전체 스캔으로 컴포넌트 수와 specVersion이 유지되는지 확인하고, 필드 호환을 점검한다. `upstream-compat.yml`을 `workflow_dispatch`로 미리 돌려 최신 버전 결과를 본다.
4. **trivy 메이저**는 보안 리포트의 검출 범위·심각도 판정을 바꿀 수 있다. 같은 SBOM에 대한 보안 리포트 요약이 어떻게 달라지는지 확인한다.
5. **`upstream-compat`가 연 이슈**가 있으면 해당 bump PR을 연결해 닫는다.

## 재릴리스 주의

같은 버전을 다시 발행하려면 태그, 릴리스, GHCR 이미지 태그를 모두 지우고 덮어야 한다. 실수가
Expand Down
Loading
Loading