diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dad96d..e0ab87e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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)" @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6ae4823..8874947 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 @@ -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 diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml new file mode 100644 index 0000000..315a5c2 --- /dev/null +++ b/.github/workflows/renovate.yml @@ -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 diff --git a/.github/workflows/upstream-compat.yml b/.github/workflows/upstream-compat.yml new file mode 100644 index 0000000..c77859b --- /dev/null +++ b/.github/workflows/upstream-compat.yml @@ -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<> "$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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 1f3d741..a4a783d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,27 +34,39 @@ LABEL org.opencontainers.image.licenses="Apache-2.0" 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(?.+)$ 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), diff --git a/docker/lib/source-detect.sh b/docker/lib/source-detect.sh index dafb8d6..2ac4ba8 100644 --- a/docker/lib/source-detect.sh +++ b/docker/lib/source-detect.sh @@ -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}" diff --git a/docs/internal/README.md b/docs/internal/README.md index 208a61d..f52b767 100644 --- a/docs/internal/README.md +++ b/docs/internal/README.md @@ -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)를 참고하세요. diff --git a/docs/internal/dependency-upgrade-policy.md b/docs/internal/dependency-upgrade-policy.md new file mode 100644 index 0000000..9b278dc --- /dev/null +++ b/docs/internal/dependency-upgrade-policy.md @@ -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을 내지 않는다. diff --git a/docs/internal/release-guide.md b/docs/internal/release-guide.md index 049a2ad..ca9f219 100644 --- a/docs/internal/release-guide.md +++ b/docs/internal/release-guide.md @@ -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 이미지 태그를 모두 지우고 덮어야 한다. 실수가 diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..9512bc1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "enabledManagers": ["custom.regex"], + "dependencyDashboard": true, + "labels": ["dependencies", "tool-upgrade"], + "commitMessagePrefix": "chore(deps):", + "prConcurrentLimit": 5, + "separateMajorMinor": true, + "packageRules": [ + { + "description": "Surface tool major bumps (e.g. cdxgen v12 -> v13) as their own PR for a deliberate, snapshot-reviewed upgrade.", + "matchUpdateTypes": ["major"], + "addLabels": ["major-upgrade"] + } + ], + "customManagers": [ + { + "customType": "regex", + "description": "Tools pinned as ARG in the scanner Dockerfile (curl/pip installed; invisible to Dependabot).", + "fileMatch": ["(^|/)docker/Dockerfile$"], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?(?: extractVersion=(?\\S+))?\\nARG \\w+=(?\\S+)" + ] + }, + { + "customType": "regex", + "description": "cdxgen language-image tag and all-in-one image pinned as shell vars in source-detect.sh.", + "fileMatch": ["(^|/)docker/lib/source-detect\\.sh$"], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)\\nCDXGEN_\\w+=\"\\$\\{CDXGEN_\\w+:-(?:ghcr\\.io/cyclonedx/cdxgen:)?(?[^}\"]+)\\}\"" + ] + } + ] +} diff --git a/tests/lib/snapshot-normalize.jq b/tests/lib/snapshot-normalize.jq new file mode 100644 index 0000000..6c20493 --- /dev/null +++ b/tests/lib/snapshot-normalize.jq @@ -0,0 +1,25 @@ +# snapshot-normalize.jq — strip volatile fields from a CycloneDX SBOM so that a +# committed golden snapshot diffs only on MEANINGFUL changes (specVersion, +# component set, fields, licenses, cpe/purl). Used by tests/test-snapshot.sh and +# the generation/upstream-compat snapshot jobs. +# +# Removed (noise on every run / every tool bump): +# - serialNumber (random per run) +# - metadata.timestamp (wall-clock) +# - metadata.tools[].version / tools.components[].version (bumps on upgrade — +# the whole point is to see what ELSE changed when a tool version moves) +# Kept on purpose: specVersion, component count/fields, licenses, cpe, purl, +# dependencies — these are the contract a tool upgrade must not silently break. + +def strip_tool_versions: + if (.metadata.tools | type) == "object" then + .metadata.tools.components = ((.metadata.tools.components // []) | map(del(.version))) + elif (.metadata.tools | type) == "array" then + .metadata.tools |= map(del(.version)) + else . end; + +del(.serialNumber) +| del(.metadata.timestamp) +| strip_tool_versions +# Deterministic component ordering so reordering alone is never a false diff. +| .components = ((.components // []) | sort_by((.purl // "") + "|" + (.name // "") + "|" + (.version // ""))) diff --git a/tests/snapshots/normalize-license-aliases.json b/tests/snapshots/normalize-license-aliases.json new file mode 100644 index 0000000..30d5a19 --- /dev/null +++ b/tests/snapshots/normalize-license-aliases.json @@ -0,0 +1,95 @@ +{ + "bomFormat": "CycloneDX", + "components": [ + { + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ], + "name": "coverage", + "type": "library", + "version": "7.4.0" + }, + { + "licenses": [ + { + "license": { + "id": "0BSD", + "url": "https://opensource.org/licenses/0BSD" + } + } + ], + "name": "flask", + "type": "library", + "version": "3.0.0" + }, + { + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "mccabe", + "type": "library", + "version": "0.7.0" + }, + { + "licenses": [ + { + "expression": "Apache-2.0 OR BSD-2-Clause" + } + ], + "name": "packaging", + "type": "library", + "version": "23.0" + }, + { + "licenses": [ + { + "expression": "Dual License" + } + ], + "name": "python-dateutil", + "type": "library", + "version": "2.9.0" + }, + { + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ], + "name": "requests", + "type": "library", + "version": "2.31.0" + }, + { + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "six", + "type": "library", + "version": "1.16.0" + } + ], + "metadata": { + "component": { + "name": "app", + "type": "application", + "version": "1.0.0" + } + }, + "specVersion": "1.6", + "version": 1 +} diff --git a/tests/snapshots/normalize-null-components.json b/tests/snapshots/normalize-null-components.json new file mode 100644 index 0000000..6bca1e3 --- /dev/null +++ b/tests/snapshots/normalize-null-components.json @@ -0,0 +1,12 @@ +{ + "bomFormat": "CycloneDX", + "components": [], + "metadata": { + "component": { + "name": "/host-output/.uploads/abc123def4567890abc123def4567890/extracted/swift", + "type": "application" + } + }, + "specVersion": "1.6", + "version": 1 +} diff --git a/tests/snapshots/vendored-merged-normalized.json b/tests/snapshots/vendored-merged-normalized.json new file mode 100644 index 0000000..e94e9ad --- /dev/null +++ b/tests/snapshots/vendored-merged-normalized.json @@ -0,0 +1,146 @@ +{ + "bomFormat": "CycloneDX", + "components": [ + { + "name": "config.c", + "properties": [ + { + "name": "bomlens:layer", + "value": "trelay" + } + ], + "purl": "pkg:generic/config.c", + "type": "file" + }, + { + "name": "lfds.c", + "properties": [ + { + "name": "bomlens:layer", + "value": "trelay" + } + ], + "purl": "pkg:generic/lfds.c", + "type": "file" + }, + { + "name": "main.c", + "properties": [ + { + "name": "bomlens:layer", + "value": "trelay" + } + ], + "purl": "pkg:generic/main.c", + "type": "file" + }, + { + "name": "ssl_lib.c", + "properties": [ + { + "name": "bomlens:layer", + "value": "trelay" + } + ], + "purl": "pkg:generic/ssl_lib.c", + "type": "file" + }, + { + "licenses": [ + { + "license": { + "name": "Unlicense" + } + } + ], + "name": "liblfds", + "properties": [ + { + "name": "bomlens:layer", + "value": "vendored" + }, + { + "name": "bomlens:identifiedBy", + "value": "scanoss" + }, + { + "name": "bomlens:scanoss:files", + "value": "2" + }, + { + "name": "bomlens:scanoss:match", + "value": "100%" + }, + { + "name": "bomlens:scanoss:purl", + "value": "pkg:github/liblfds/liblfds" + }, + { + "name": "bomlens:layer", + "value": "layer-1" + } + ], + "purl": "pkg:github/liblfds/liblfds", + "type": "library", + "version": "6.1.1" + }, + { + "cpe": "cpe:2.3:a:openssl:openssl:3.0.0:*:*:*:*:*:*:*", + "licenses": [ + { + "license": { + "name": "Apache-2.0" + } + } + ], + "name": "openssl", + "properties": [ + { + "name": "bomlens:layer", + "value": "vendored" + }, + { + "name": "bomlens:identifiedBy", + "value": "scanoss" + }, + { + "name": "bomlens:scanoss:files", + "value": "2" + }, + { + "name": "bomlens:scanoss:match", + "value": "100%" + }, + { + "name": "bomlens:scanoss:purl", + "value": "pkg:github/openssl/openssl" + }, + { + "name": "bomlens:layer", + "value": "layer-1" + } + ], + "purl": "pkg:github/openssl/openssl", + "type": "library", + "version": "3.0.0" + } + ], + "dependencies": [], + "metadata": { + "component": { + "name": "trelay", + "type": "application", + "version": "26.4.0" + }, + "tools": { + "components": [ + { + "name": "bomlens-merge", + "type": "application" + } + ] + } + }, + "specVersion": "1.6", + "version": 1 +} diff --git a/tests/test-snapshot.sh b/tests/test-snapshot.sh new file mode 100644 index 0000000..4d631c8 --- /dev/null +++ b/tests/test-snapshot.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# Copyright 2026 SK Telecom Co., Ltd. +# Licensed under the Apache License, Version 2.0. +# +# test-snapshot.sh — output regression snapshots for the SBOM post-processing +# pipeline. Runs the real post-process scripts over fixed input fixtures, +# strips volatile fields (tests/lib/snapshot-normalize.jq), and diffs the result +# against committed golden snapshots in tests/snapshots/. +# +# Why: the other unit tests assert a handful of fields. A bump to a tool or a jq +# change can alter the output in ways those asserts miss (specVersion, dropped +# fields, license shape, cpe synthesis). The snapshot makes ANY such change show +# up as a reviewable diff instead of a silent regression. +# +# bash tests/test-snapshot.sh # compare against goldens (CI) +# UPDATE_SNAPSHOTS=1 bash tests/test-snapshot.sh # regenerate goldens after +# # an intended change +# +# Pure jq/bash — no Docker. Covers OUR pipeline. Drift caused by a tool VERSION +# (cdxgen/syft emitting different output) is caught by the generation snapshot in +# the docker / upstream-compat jobs, which reuse the same normalizer. +set -u + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LIB="$ROOT_DIR/docker/lib" +FIX="$ROOT_DIR/tests/fixtures" +SNAP_DIR="$ROOT_DIR/tests/snapshots" +NORM="$ROOT_DIR/tests/lib/snapshot-normalize.jq" +UPDATE="${UPDATE_SNAPSHOTS:-0}" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +# shellcheck disable=SC2001 # sed prefixes every diff line; ${//} can't do per-line +fail() { echo " FAIL: $1"; [ -n "${2:-}" ] && echo "$2" | sed 's/^/ /'; FAIL=$((FAIL + 1)); } + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq is required for snapshot tests"; exit 1 +fi + +mkdir -p "$SNAP_DIR" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# check_snapshot +# Normalizes the produced SBOM and compares to tests/snapshots/.json, or +# (re)writes the golden when UPDATE_SNAPSHOTS=1. +check_snapshot() { + local name="$1" produced="$2" + local golden="$SNAP_DIR/${name}.json" + local norm="$WORK/${name}.norm.json" + if ! jq -S -f "$NORM" "$produced" > "$norm" 2>"$WORK/${name}.err"; then + fail "$name: normalize failed" "$(cat "$WORK/${name}.err")" + return + fi + if [ "$UPDATE" = "1" ]; then + cp "$norm" "$golden" + echo " WROTE: $name" + return + fi + if [ ! -f "$golden" ]; then + fail "$name: golden missing ($golden). Seed it with UPDATE_SNAPSHOTS=1." + return + fi + if diff -u "$golden" "$norm" > "$WORK/${name}.diff" 2>&1; then + pass "$name matches golden" + else + fail "$name drifted from golden (review the change; if intended: UPDATE_SNAPSHOTS=1)" \ + "$(head -40 "$WORK/${name}.diff")" + fi +} + +echo "== snapshot: normalize-sbom.sh on license aliases ==" +cp "$FIX/license-aliases.json" "$WORK/lic.json" +bash "$LIB/normalize-sbom.sh" "$WORK/lic.json" >/dev/null 2>&1 +check_snapshot "normalize-license-aliases" "$WORK/lic.json" + +echo "== snapshot: normalize-sbom.sh coerces null components ==" +cp "$FIX/null-components.json" "$WORK/nul.json" +bash "$LIB/normalize-sbom.sh" "$WORK/nul.json" >/dev/null 2>&1 +check_snapshot "normalize-null-components" "$WORK/nul.json" + +echo "== snapshot: vendored identify -> merge -> normalize (PURL->CPE chain) ==" +# Mock scanoss-py so no network/image is needed (mirrors test-postprocess.sh). +mkdir -p "$WORK/bin" "$WORK/srctree/src" +echo 'int main(void){return 0;}' > "$WORK/srctree/src/main.c" +cat > "$WORK/bin/scanoss-py" <<'MOCK' +#!/bin/bash +out=""; prev="" +for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; done +[ -n "$out" ] && cp "$SCANOSS_RAW_FIXTURE" "$out" +exit 0 +MOCK +chmod +x "$WORK/bin/scanoss-py" +export SCANOSS_RAW_FIXTURE="$FIX/scanoss-raw.json" +PATH="$WORK/bin:$PATH" bash "$LIB/identify-vendored.sh" "$WORK/srctree" "$WORK/vend.json" "26.4.0" >/dev/null 2>&1 +bash "$LIB/merge-sbom.sh" "$WORK/merged.json" "trelay" "26.4.0" \ + "$FIX/cdxgen-cpp-sparse.json" "$WORK/vend.json" >/dev/null 2>&1 +bash "$LIB/normalize-sbom.sh" "$WORK/merged.json" >/dev/null 2>&1 +check_snapshot "vendored-merged-normalized" "$WORK/merged.json" + +echo "" +if [ "$UPDATE" = "1" ]; then + echo "Snapshots written to $SNAP_DIR" + exit 0 +fi +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ]