# ========================================================
# BomLens — post-processing image (2-stage architecture)
# Image: ghcr.io/sktelecom/sbom-scanner
#
# In the redesigned pipeline, SBOM GENERATION for
# source code is delegated to cdxgen's official per-language images (pulled
# on-demand by scripts/scan-sbom.sh). THIS image is the lightweight
# post-processor + syft scanner; it carries NO language toolchains.
#
# Responsibilities:
#   - syft scanning of container images / binaries / RootFS
#   - normalize, open-source notice, Trivy security report, cosign signing
#   - the local web UI (React SPA, built in stage 1; served by Python stdlib)
# ========================================================

# Selects the CPE index stage below (true builds it, false leaves it out). Global
# because a FROM line reads it; the runtime stage declares it again for its label.
ARG SBOM_CPE_INDEX=true

# ---- Stage 1: build the React SPA (Vite). node lives ONLY in this stage; the
#      runtime image copies just the static dist/, so it stays lightweight. ----
FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba AS web
WORKDIR /web
COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm ci || npm install
COPY web/frontend/ ./
RUN npm run build

# ---- CPE index stages: the NVD CPE -> CVE applicability index ----
# lib/match-cpe-index.sh (through lib/firmware-cpe-match.py) matches the CPEs of
# components with no package purl against this index, for firmware, rootfs and
# binary scans. cpe_match.sqlite (~146 MB) is the application-part CPE->CVE
# index, distilled by build-cpe-index.py from the fkie-cad NVD 2.0 JSON feeds,
# fetched with a plain git clone (no api2 rate limit, no key).
#
# The data does not depend on the target architecture or on the image flavor, so
# it is built in its own stage on the BUILD platform and copied into the runtime
# stage. One build serves amd64 and arm64 (arm64 is not emulated for it), and its
# layer cache is shared by every image built from this Dockerfile: the stage reads
# no per-release ARG (BOMLENS_VERSION, the SBOM_* flavor flags). NVD_FEED_REF pins
# the feed commit. The publish workflow resolves it once per run, so all images
# of a release carry the same index and a new feed commit rebuilds it; left empty,
# the build clones the feed's current HEAD.
#
# SBOM_CPE_INDEX (declared at the top of the file) selects the stage: false picks
# the empty one, so the clone never runs (for a local build with no network
# access to GitHub). A firmware build refuses false.

FROM --platform=$BUILDPLATFORM python:3.12-slim@sha256:23b5dc88c7dd47fec3f960b51dc30d19df9875cfbfc60f3b62d3e5b88cbccf62 AS cpe-index-true
# renovate: datasource=git-refs depName=fkie-cad/nvd-json-data-feeds
ARG NVD_FEED_REPO="https://github.com/fkie-cad/nvd-json-data-feeds"
ARG NVD_FEED_REF=
RUN apt-get update \
    && apt-get install -y --no-install-recommends git ca-certificates \
    && rm -rf /var/lib/apt/lists/*
COPY build-cpe-index.py check-version-string-map.py /build/
COPY lib/firmware-cpe-match.py lib/version-string-map.json /build/
# RELEASE GATE: the build FAILS unless the index has a substantial row count AND a
# BusyBox 1.30.1 and a Linux kernel 4.4.153 smoke match each yield >=1 CVE. The
# smoke tests run the real matcher, so a broken distill or an empty feed cannot
# ship a silent 0-CVE image.
RUN set -e; \
    fetch_hint="[build] For a local build without access to $NVD_FEED_REPO, pass --build-arg SBOM_CPE_INDEX=false: the image then has no CPE index, and firmware, rootfs and binary scans report no CVEs for components that have no package purl."; \
    echo "[build] fetching NVD data feeds (shallow${NVD_FEED_REF:+, commit $NVD_FEED_REF}) ..."; \
    if [ -n "$NVD_FEED_REF" ]; then \
      if ! { git init --quiet /tmp/nvd-feeds \
             && git -C /tmp/nvd-feeds fetch --quiet --depth 1 "$NVD_FEED_REPO" "$NVD_FEED_REF" \
             && git -C /tmp/nvd-feeds checkout --quiet FETCH_HEAD; }; then \
        echo "[build] ERROR: could not fetch commit $NVD_FEED_REF of the NVD data feeds from $NVD_FEED_REPO."; \
        echo "$fetch_hint"; exit 1; \
      fi; \
    elif ! git clone --quiet --depth 1 "$NVD_FEED_REPO" /tmp/nvd-feeds; then \
      echo "[build] ERROR: could not clone the NVD data feeds from $NVD_FEED_REPO."; \
      echo "$fetch_hint"; exit 1; \
    fi; \
    echo "[build] distilling CPE index ..."; \
    mkdir -p /out; \
    idx=/out/cpe_match.sqlite; \
    python3 /build/build-cpe-index.py /tmp/nvd-feeds "$idx"; \
    rm -rf /tmp/nvd-feeds; \
    rows="$(python3 -c 'import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute("select count(*) from cpe_match").fetchone()[0])' "$idx" 2>/dev/null || echo 0)"; \
    printf '%s' '{"components":[{"type":"application","name":"busybox","version":"1.30.1","cpe":"cpe:/a:busybox:busybox:1.30.1"}]}' > /tmp/smoke.json; \
    nsmoke="$(python3 /build/firmware-cpe-match.py /tmp/smoke.json "$idx" 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)"; \
    # The kernel is filed by NVD under CPE part `o`, and the index keeps only \
    # part `a` plus a closed list of `o` pairs. It is the one component every \
    # rootfs carries, so a build that dropped it would return nothing for all \
    # of them. Check it here rather than finding out on a scan. \
    printf '%s' '{"components":[{"type":"application","name":"linux_kernel","version":"4.4.153","cpe":"cpe:/a:linux:linux_kernel:4.4.153"}]}' > /tmp/smoke-kernel.json; \
    nkernel="$(python3 /build/firmware-cpe-match.py /tmp/smoke-kernel.json "$idx" 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)"; \
    echo "[build] CPE index: rows=${rows:-0}, busybox-1.30.1 smoke CVEs=${nsmoke:-0}, linux_kernel-4.4.153 smoke CVEs=${nkernel:-0} ($(du -h "$idx" | cut -f1))"; \
    if [ "${rows:-0}" -lt 500000 ] || [ "${nsmoke:-0}" -lt 1 ] || [ "${nkernel:-0}" -lt 1 ]; then \
      echo "[build] ERROR: CPE index looks broken (rows=${rows:-0}, busybox smoke=${nsmoke:-0}, kernel smoke=${nkernel:-0}). Build aborted."; \
      exit 1; \
    fi; \
    echo "[build] checking version-string-map CPEs against the index ..."; \
    python3 /build/check-version-string-map.py /build/version-string-map.json "$idx"; \
    rm -f /tmp/smoke.json /tmp/smoke-kernel.json

# SBOM_CPE_INDEX=false: an empty /out, so the runtime stage's COPY still works.
FROM --platform=$BUILDPLATFORM python:3.12-slim@sha256:23b5dc88c7dd47fec3f960b51dc30d19df9875cfbfc60f3b62d3e5b88cbccf62 AS cpe-index-false
RUN mkdir -p /out

# hadolint ignore=DL3006
FROM cpe-index-${SBOM_CPE_INDEX} AS cpe-index

# ---- govulncheck stage: Go reachability (--reachability) ----
# govulncheck has no release binaries, so it is built here from the pinned module
# version and copied into the runtime stage as one static executable; no Go
# toolchain goes into the image. Built on the BUILD platform and cross-compiled
# for the target (no emulation). The module's h1 hash is checked against the pin
# below before the build (go also checks it against the Go checksum database),
# so a changed release cannot slip in under the same version. Go 1.27 builds it:
# govulncheck type-checks the scanned source with the Go it was built with, so
# it reads modules up to that Go version. BSD-3-Clause (THIRD_PARTY_LICENSES.md).
FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS govulncheck
ARG TARGETARCH
# renovate: datasource=go depName=golang.org/x/vuln
ARG GOVULNCHECK_VERSION=v1.8.0
ARG GOVULNCHECK_MODULE_SUM=h1:clG4qBU6zH5VKjti8n5j8BBuYzoSha392xXMkXS351U=
ENV GOTOOLCHAIN=local CGO_ENABLED=0 GOFLAGS=-trimpath
RUN set -e; \
    got="$(go mod download -json "golang.org/x/vuln@${GOVULNCHECK_VERSION}" | sed -n 's/^[[:space:]]*"Sum": "\(.*\)",*$/\1/p')"; \
    if [ "$got" != "$GOVULNCHECK_MODULE_SUM" ]; then \
      echo "[build] ERROR: golang.org/x/vuln@${GOVULNCHECK_VERSION} hash is '$got', expected '$GOVULNCHECK_MODULE_SUM'."; exit 1; \
    fi; \
    GOOS=linux GOARCH="${TARGETARCH}" GOBIN= go install -ldflags="-s -w" "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}"; \
    out="/go/bin/govulncheck"; [ -x "$out" ] || out="/go/bin/linux_${TARGETARCH}/govulncheck"; \
    install -D -m 0755 "$out" /out/govulncheck

# ---- Stage 2: post-process runtime ----
# python 3.12: the base only needs stdlib (web server.py), but the opt-in scancode/
# firmware tools (pinned) do not yet support newer Python, so we stay on 3.12.
FROM python:3.12-slim@sha256:23b5dc88c7dd47fec3f960b51dc30d19df9875cfbfc60f3b62d3e5b88cbccf62

LABEL org.opencontainers.image.title="BomLens (post-process)"
LABEL org.opencontainers.image.description="syft + Trivy + notice + signing + UI; SBOM generation delegated to cdxgen language images"
LABEL org.opencontainers.image.vendor="SK Telecom"
LABEL org.opencontainers.image.licenses="Apache-2.0"
# The engine contract this image follows (docs/reference/engine-contract.md),
# the same value as ENGINE_CONTRACT_VERSION in docker/lib/engine-result.sh.
# The CLI reads it to tell an image too old for the stage 1 hand-off.
LABEL com.sktelecom.sbom.engine-contract="1.3.0"

ENV DEBIAN_FRONTEND=noninteractive

# The image's own release, so an SBOM this pipeline generates can name the tool
# version that produced it — an SBOM minimum element. The publish workflow passes
# the image's version tag: the release version on a tagged build, `latest` on a
# build from main, which is what the reader pulled either way. A local build leaves
# it empty and the SBOM reports the version as unknown, which is what the minimum
# elements ask for when no version identifier is available.
ARG BOMLENS_VERSION=
ENV BOMLENS_VERSION=${BOMLENS_VERSION}

# 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.54.1
# renovate: datasource=github-releases depName=aquasecurity/trivy
ARG TRIVY_VERSION=v0.75.0
# renovate: datasource=github-releases depName=sigstore/cosign
ARG COSIGN_VERSION=v3.1.3
# renovate: datasource=pypi depName=scancode-toolkit
ARG SCANCODE_VERSION=32.5.0
# Pinned beside scancode in the deep-license image: chardet stays on 6.x, and
# intbitset and the amd64 7-Zip plugin match the source bundled for them
# (deep-license-sources.sh, which checks both at build time).
ARG CHARDET_VERSION=6.0.0.post1
ARG INTBITSET_VERSION=4.1.2
ARG INTBITSET_SDIST_SHA256=f82fafe0bcb4fe7a010d942099b5a84d721d37c897538ed632f788962c1411f8
ARG EXTRACTCODE_7Z_VERSION=16.5.210531
# pluggy 1.7.0 makes scancode 32.5.0 fail at start-up ("did not find any
# 'pre_scan' hooks"), which stops the deep-license image build at its probe scan.
# Stay on the version the image was last built and verified with, and revisit it
# when scancode-toolkit moves. No renovate comment on purpose: a bump to 1.7.0
# alone would break the build again.
ARG PLUGGY_VERSION=1.6.0
ARG SBOM_DEEP_LICENSE=false
# Vendored-OSS identification (on by default; disable with --build-arg SBOM_SCANOSS=false).
# scanoss.py is MIT, so unlike the firmware GPL tools it can live in the base image.
# Runtime still gates the outbound OSSKB call behind the --identify-vendored flag,
# so shipping the client just makes the web-UI toggle available out of the box.
ARG SBOM_SCANOSS=true
# renovate: datasource=pypi depName=scanoss
ARG SCANOSS_VERSION=1.54.2
# renovate: datasource=pypi depName=picklescan
ARG PICKLESCAN_VERSION=1.0.5
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-tags depName=docker/cli extractVersion=^v(?<version>.+)$
ARG DOCKER_CLI_VERSION=29.9.0

# Firmware analysis (opt-in image `sbom-scanner-firmware`). The GPL analysis
# tools are isolated here so the base image carries only permissively licensed
# tools of our own choosing — its Debian base contributes GPL system packages
# either way, as every Linux image does; see THIRD_PARTY_LICENSES.md.
ARG SBOM_FIRMWARE=false
# The NVD CPE index (cpe_match.sqlite, built in the cpe-index stage above) is in
# every image, so a rootfs or binary scan matches the CPEs of components that
# carry no package purl offline. Pure Python and public-domain NVD data, no GPL
# tool. SBOM_CPE_INDEX=false leaves it out; a firmware build requires it.
ARG SBOM_CPE_INDEX
# 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.15
# The firmware SBOM names the tools that produced it, and a tool name without a
# version tells a reader nothing about which build ran. Carry the pins forward as
# ENV so scan-firmware.sh states them exactly instead of parsing --version output.
ENV BOMLENS_SYFT_VERSION=${SYFT_VERSION} \
    BOMLENS_UNBLOB_VERSION=${UNBLOB_VERSION} \
    BOMLENS_CVE_BIN_TOOL_VERSION=${CVE_BIN_TOOL_VERSION}
# sasquatch — the patched unsquashfs that reads vendor squashfs variants standard
# squashfs-tools rejects. GPL-2.0, from the unblob authors, shipped as a prebuilt
# .deb so nothing is compiled here.
# renovate: datasource=github-releases depName=onekey-sec/sasquatch
ARG SASQUATCH_VERSION=sasquatch-v4.5.1-6

# Deep-CVE matching (opt-in image `bomlens-deep-cve`). grype adds a CPE matcher
# that reaches the NVD-only maven CVEs Trivy misses (older Apache libs keyed in
# NVD by CPE, absent from GitHub Security Advisory). Its vulnerability DB is large
# (~1.8 GB), so it is kept out of the base image. Apache-2.0.
ARG SBOM_DEEP_CVE=false
# renovate: datasource=github-releases depName=anchore/grype
ARG GRYPE_VERSION=v0.120.1

# AI SBOM generation (opt-in image `bomlens-aibom`). owasp-aibom-generator is
# Apache-2.0 and fetches HuggingFace model-card metadata over the network at scan
# time. Kept opt-in (network + extra deps) like SCANOSS so the base image stays
# local-first. No PyPI release yet, so it is pinned to a commit; when the project
# tags releases, Renovate's github-tags datasource picks them up.
ARG SBOM_AIBOM=false
# renovate: datasource=github-tags depName=GenAI-Security-Project/aibom-generator
ARG AIBOM_GENERATOR_REF=c5a3f2f647b801ab4ab7569aade4a6cdaf1f9591
# cdxgen (Node) is installed alongside the AIBOM generator so enrich-aibom.sh can
# harvest model pedigree / performance metrics that the OWASP tool omits. Opt-in
# with the aibom image only; enrich-aibom.sh degrades gracefully when it is absent.
# renovate: datasource=npm depName=@cdxgen/cdxgen
ARG CDXGEN_VERSION=13.5.0
# cdxgen 13 requires Node.js >= 24; Debian's own nodejs package (bundled in the
# python:3.12-slim base) is still on the 20.x line, so Node is installed from
# the official upstream release tarball instead of apt. Pinned like syft/trivy/
# cosign/docker above (this file's established pattern for external tools),
# arch-mapped via uname -m.
# renovate: datasource=github-releases depName=nodejs/node
ARG NODE_VERSION=v24.22.1
# npm as bundled with that Node release carries its own vulnerable copies of
# tar, ip-address and brace-expansion, and npm stays in the image (it installs
# cdxgen), so it is replaced by a pinned release with those fixed.
# renovate: datasource=npm depName=npm
ARG NPM_VERSION=11.20.0

# NOTICE PDF rendering (opt-in: --build-arg SBOM_PDF=true). weasyprint renders the
# generated _NOTICE.html to _NOTICE.pdf. It is Python-based (consistent with onot)
# but needs heavy native libraries (pango/cairo/gdk-pixbuf), so it is kept out of
# the lean base image and generate-notice.sh skips PDF gracefully when absent.
ARG SBOM_PDF=false
# renovate: datasource=pypi depName=weasyprint
ARG WEASYPRINT_VERSION=63.1

# jq (helper scripts), curl/tar (installers), file (binary mode),
# git (web UI 'GitHub URL' ingestion: server.py clones into a temp source tree).
# zstd: a Yocto SPDX 2.2 build publishes its package documents as an
# <image>.spdx.tar.zst beside the image document (parse-yocto-spdx.py reads it
# through `zstd -dc`; python 3.12's tarfile cannot decompress zstd itself).
# python3 + pip are already in the python:3.12-slim base (web UI + scancode).
#
# `apt-get upgrade` runs first and on its own: the base image is pinned by
# digest (renovate.json only enables custom.regex, not the Docker manager, so
# that digest never gets a bump PR), and Debian's security archive keeps
# publishing patched packages for the same release long after the digest was
# built. Without this, glibc/openssl/pcre2/util-linux/curl etc. stay frozen at
# whatever the pinned digest shipped, which is exactly the CVE backlog a
# nightly `trivy image` scan (--ignore-unfixed) keeps finding — a fix already
# exists in the Debian archive, this step is what actually pulls it in.
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
    jq curl wget ca-certificates bash tar file git zstd \
    && rm -rf /var/lib/apt/lists/*

# pip itself ships pinned inside the base image and carries its own CVEs
# (dependency resolver / archive-handling bugs) independent of anything we
# install with it; nothing above upgrades it.
RUN python3 -m pip install --no-cache-dir --upgrade pip

# syft — image/binary/RootFS scanning (pinned)
RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
    | sh -s -- -b /usr/local/bin "${SYFT_VERSION}" \
    && syft version

# Trivy — security report engine (pinned release tarball; arch-mapped).
# Detect arch with `uname -m` (under buildx QEMU this reports the TARGET arch),
# not the TARGETARCH ARG whose Dockerfile default ("amd64") shadowed the
# per-platform value and pulled an amd64 binary into the arm64 image.
RUN TRIVY_VER_NUM="${TRIVY_VERSION#v}" \
    && case "$(uname -m)" in \
         x86_64)  TRIVY_ARCH="Linux-64bit" ;; \
         aarch64) TRIVY_ARCH="Linux-ARM64" ;; \
         *)       TRIVY_ARCH="Linux-64bit" ;; \
       esac \
    && curl -sSfL "https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VER_NUM}_${TRIVY_ARCH}.tar.gz" -o /tmp/trivy.tgz \
    && tar -C /usr/local/bin -xzf /tmp/trivy.tgz trivy \
    && rm /tmp/trivy.tgz \
    && trivy --version

# Cosign — optional SBOM signing (--sign), pinned (arch via uname -m, see above)
RUN case "$(uname -m)" in x86_64) CARCH=amd64 ;; aarch64) CARCH=arm64 ;; *) CARCH=amd64 ;; esac \
    && curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${CARCH}" -o /usr/local/bin/cosign \
    && chmod +x /usr/local/bin/cosign \
    && cosign version

# docker CLI (client binary only; no daemon). The web UI source scan shells out
# to `docker run <cdxgen-language-image>` against the mounted host socket. Pulled
# from Docker's static builds, arch-mapped via uname -m (see Trivy note above).
RUN case "$(uname -m)" in \
         x86_64)  DOCKER_ARCH="x86_64" ;; \
         aarch64) DOCKER_ARCH="aarch64" ;; \
         *)       DOCKER_ARCH="x86_64" ;; \
       esac \
    && curl -sSfL "https://download.docker.com/linux/static/stable/${DOCKER_ARCH}/docker-${DOCKER_CLI_VERSION}.tgz" -o /tmp/docker.tgz \
    && tar -C /usr/local/bin --strip-components=1 -xzf /tmp/docker.tgz docker/docker \
    && rm /tmp/docker.tgz \
    && docker --version

# scancode-toolkit (opt-in deep license: --build-arg SBOM_DEEP_LICENSE=true).
# scancode has three native dependencies that a plain `pip install` does NOT satisfy
# on the slim base, so all three are handled here:
#   1. PyICU (transitive) is a C extension compiled against ICU headers, so the build
#      needs build-essential + libicu-dev + pkg-config + python3-dev. Those dev
#      packages are purged afterward; the runtime libicu is re-marked manual so
#      autoremove keeps it for the compiled PyICU to load.
#   2. extractcode loads libarchive via ctypes at import time. Its loader calls
#      find_library("libarchive"), which ALWAYS returns None on Linux (ctypes wants
#      the name WITHOUT the lib prefix), then loads the main program and dies with
#      "undefined symbol: archive_read_new". We install the system libarchive13 and
#      point EXTRACTCODE_LIBARCHIVE_PATH straight at it (the loader's first choice),
#      sidestepping the broken find_library path. The pip extractcode-libarchive
#      plugin is a no-arch stub here (ships no .so), so it does not help.
#   3. typecode loads libmagic the same way; TYPECODE_LIBMAGIC_PATH / _DB_PATH pin
#      the system libmagic1 and its compiled magic database.
# A real license scan runs as a build gate so any missing native dep fails the build
# instead of silently shipping a scancode that dies on the first scan.
COPY deep-license-sources.sh /tmp/deep-license-sources.sh
ENV EXTRACTCODE_LIBARCHIVE_PATH=/usr/local/lib/libarchive.so \
    TYPECODE_LIBMAGIC_PATH=/usr/local/lib/libmagic.so \
    TYPECODE_LIBMAGIC_DB_PATH=/usr/share/misc/magic.mgc
RUN if [ "$SBOM_DEEP_LICENSE" = "true" ]; then \
      apt-get update && apt-get install -y --no-install-recommends \
        build-essential libicu-dev pkg-config python3-dev \
        libarchive13 libmagic1 \
      && sevenzip_pin="" \
      && if [ "$(uname -m)" = "x86_64" ]; then sevenzip_pin="extractcode-7z==${EXTRACTCODE_7Z_VERSION}"; fi \
      && pip3 install --no-cache-dir "scancode-toolkit==${SCANCODE_VERSION}" \
           "chardet==${CHARDET_VERSION}" "intbitset==${INTBITSET_VERSION}" "pluggy==${PLUGGY_VERSION}" ${sevenzip_pin} \
      && ln -sf "$(find /usr/lib -name 'libarchive.so.13' | head -1)" /usr/local/lib/libarchive.so \
      && ln -sf "$(find /usr/lib -name 'libmagic.so.1' | head -1)" /usr/local/lib/libmagic.so \
      && mkdir -p /tmp/sc-probe \
      && printf 'Permission is hereby granted, free of charge (MIT License).\n' > /tmp/sc-probe/LICENSE \
      && scancode --license --json-pp /tmp/sc-probe.json /tmp/sc-probe \
      && rm -rf /tmp/sc-probe /tmp/sc-probe.json \
      && INTBITSET_VERSION="${INTBITSET_VERSION}" INTBITSET_SDIST_SHA256="${INTBITSET_SDIST_SHA256}" \
           sh /tmp/deep-license-sources.sh \
      && apt-mark manual $(dpkg-query -W -f='${Package}\n' 'libicu[0-9]*') \
      && apt-get purge -y build-essential libicu-dev pkg-config python3-dev \
      && apt-get autoremove -y \
      && rm -rf /var/lib/apt/lists/*; \
    else \
      echo "[build] scancode-toolkit skipped (SBOM_DEEP_LICENSE=false)"; \
    fi \
    && rm -f /tmp/deep-license-sources.sh

# scanoss.py (vendored-OSS identification, on by default: --build-arg SBOM_SCANOSS=false to skip).
# MIT-licensed client; talks to the hosted OSSKB API (or SCANOSS_API_URL). It does
# NOT bundle the GPL-2.0 SCANOSS engine. See identify-vendored.sh and
# THIRD_PARTY_LICENSES.md for the OSSKB terms (identification-only, no redistribution).
RUN if [ "$SBOM_SCANOSS" = "true" ]; then \
      pip3 install --no-cache-dir "scanoss==${SCANOSS_VERSION}" \
      && scanoss-py --version; \
    else \
      echo "[build] scanoss skipped (SBOM_SCANOSS=false)"; \
    fi

# picklescan (pickle-format model weights, always installed).
#
# Pickle executes code as it loads, so a weight file in that format is a
# supply-chain risk of its own. HuggingFace runs this same tool over the files
# it hosts and BomLens reads the verdict from the Hub — which leaves nothing at
# all for a model file that was never published, the case MODE=MODELFILE exists
# for. scan-model-file-security.py runs it locally so that path has a verdict
# too.
#
# In the BASE image rather than an opt-in one: MIT, pure Python, 24 KB, no
# required dependencies and no network. Nothing here resembles the reasons the
# firmware, deep-CVE and AIBOM tools were isolated (GPL, gigabytes of database,
# outbound calls). Verified at build time so a missing tool fails the build
# instead of silently degrading every model-file scan to "not scanned".
RUN pip3 install --no-cache-dir "picklescan==${PICKLESCAN_VERSION}" \
    && picklescan --help > /dev/null \
    && python3 -c "import picklescan.scanner"

# Firmware unpack + binary identification (opt-in: --build-arg SBOM_FIRMWARE=true).
#   unblob = MIT (primary unpacker); cve-bin-tool/ubi_reader = GPL (binary ID, UBI).
#   squashfs-tools/e2fsprogs/p7zip/unar/jefferson/... are the extractor binaries
#   unblob shells out to; two of them are also used directly by scan-firmware.sh
#   as fallbacks — unsquashfs for standard squashfs when unblob's sasquatch
#   handler is absent, and 7z for the Windows installer containers (NSIS, Inno
#   Setup, self-extracting cabinets) that unblob does not unpack at all.
#   Built only into ghcr.io/sktelecom/sbom-scanner-firmware so the base stays GPL-free.
#   NOTE: the PyPI `binwalk` 2.x dist is broken (no binwalk.core), so it is NOT
#   installed; unsquashfs covers the common squashfs case.
#
#   sasquatch covers what unsquashfs will not touch: a vendor image whose magic is
#   byte-swapped, and the LZMA flavours that predate the mainline format. A Zyxel
#   switch firmware keeps its whole system in such an image, and without this the
#   scan reads the boot initramfs and reports one component instead of the system.
#   Prebuilt .deb (187 KB), so no compiler is pulled in. The build FAILS if the
#   binary is not runnable afterwards, rather than shipping a silent gap.
#
#   binutils is named for readelf, which identify-elf-presence.py reads SONAME and
#   NEEDED with. It was already arriving as somebody else's dependency, which is
#   how a runtime file goes missing from a release without any build failing.
#
#   firmware-pip-overrides.txt then raises two packages gsutil (a cve-bin-tool
#   dependency) pins at vulnerable versions; the file records why that is safe.
COPY firmware-pip-overrides.txt /tmp/firmware-pip-overrides.txt
RUN if [ "$SBOM_FIRMWARE" = "true" ]; then \
      apt-get update && apt-get install -y --no-install-recommends \
        squashfs-tools e2fsprogs p7zip-full unar cpio cabextract \
        lzop zstd lz4 liblzo2-2 zlib1g binutils \
      && pip3 install --no-cache-dir \
        "unblob==${UNBLOB_VERSION}" \
        "cve-bin-tool==${CVE_BIN_TOOL_VERSION}" \
        "ubi_reader==${UBI_READER_VERSION}" \
      && pip3 install --no-cache-dir -r /tmp/firmware-pip-overrides.txt \
      && arch="$(dpkg --print-architecture)" \
      && curl -fsSL -o /tmp/sasquatch.deb \
         "https://github.com/onekey-sec/sasquatch/releases/download/${SASQUATCH_VERSION}/sasquatch_1.0_${arch}.deb" \
      && dpkg -i /tmp/sasquatch.deb && rm -f /tmp/sasquatch.deb \
      && rm -rf /var/lib/apt/lists/* \
      && unblob --version && cve-bin-tool --version \
      && command -v sasquatch \
      && (sasquatch -version 2>&1 | head -1 || true) \
      && readelf --version | head -1; \
    else \
      echo "[build] firmware tools skipped (SBOM_FIRMWARE=false)"; \
    fi \
    && rm -f /tmp/firmware-pip-overrides.txt

# Deep-CVE: grype + its vulnerability DB, baked in for offline use (opt-in image
# bomlens-deep-cve). scan-nvd-cpe.py runs grype's CPE matcher over the maven CPEs
# enrich-maven-cpe attaches, recovering the NVD-only maven CVEs Trivy misses.
# `grype db update` caches the DB under GRYPE_DB_CACHE_DIR at build time so the
# image needs no network at scan time; auto-update is disabled at runtime. The
# build FAILS if the DB is not valid, so a broken cache cannot ship a silent 0-CVE
# deep-cve image.
ENV GRYPE_DB_CACHE_DIR=/opt/grype-db \
    GRYPE_DB_AUTO_UPDATE=false
RUN if [ "$SBOM_DEEP_CVE" = "true" ]; then \
      set -e; \
      GRYPE_VER_NUM="${GRYPE_VERSION#v}"; \
      case "$(uname -m)" in \
        x86_64)  GRYPE_ARCH="amd64" ;; \
        aarch64) GRYPE_ARCH="arm64" ;; \
        *)       GRYPE_ARCH="amd64" ;; \
      esac; \
      curl -sSfL "https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}/grype_${GRYPE_VER_NUM}_linux_${GRYPE_ARCH}.tar.gz" -o /tmp/grype.tgz; \
      tar -C /usr/local/bin -xzf /tmp/grype.tgz grype; \
      rm /tmp/grype.tgz; \
      grype version; \
      mkdir -p "$GRYPE_DB_CACHE_DIR"; \
      GRYPE_DB_AUTO_UPDATE=true grype db update; \
      grype db status; \
      grype db status -o json | grep -q '"valid": *true' || { echo "[build] ERROR: grype DB not valid; deep-cve build aborted."; exit 1; }; \
    else \
      echo "[build] deep-cve tools skipped (SBOM_DEEP_CVE=false)"; \
    fi

# CPE -> CVE matching data, for firmware, rootfs and binary scans. Two artifacts:
#   (1) a tiny STUB cve.db, firmware image only: cve-bin-tool refuses to run on
#       an empty cache (EmptyCache in cvedb.check_cve_entries), so we seed its
#       schema with one sentinel row. Identification never reads CVE data from it. (~57 KB)
#   (2) cpe_match.sqlite, every image unless SBOM_CPE_INDEX=false, built and
#       checked by the release gate in the cpe-index stage at the top of this
#       file. A firmware build without it is refused: firmware scans depend on it.
ENV CVE_BIN_TOOL_HOME=/opt/cve-bin-tool-home
# Attribution + required notices for the bundled NVD-derived data. Shipped with it.
COPY CVE-DATA-NOTICE.txt /opt/cve-bin-tool-home/CVE-DATA-NOTICE.txt
COPY --from=cpe-index /out/ /opt/cve-bin-tool-home/
# govulncheck (--reachability), built in the govulncheck stage above.
COPY --from=govulncheck /out/govulncheck /usr/local/bin/govulncheck
RUN set -e; \
    if [ "$SBOM_FIRMWARE" = "true" ]; then \
      if [ ! -s "$CVE_BIN_TOOL_HOME/cpe_match.sqlite" ]; then \
        echo "[build] ERROR: the firmware image needs the CPE index; drop --build-arg SBOM_CPE_INDEX=false for SBOM_FIRMWARE=true."; \
        exit 1; \
      fi; \
      echo "[build] seeding stub cve.db (cve-bin-tool EmptyCache guard) ..."; \
      mkdir -p "$CVE_BIN_TOOL_HOME/.cache/cve-bin-tool"; \
      HOME="$CVE_BIN_TOOL_HOME" python3 -c "import sqlite3, os; from cve_bin_tool.cvedb import CVEDB; CVEDB().init_database(); p=os.path.expanduser('~/.cache/cve-bin-tool/cve.db'); c=sqlite3.connect(p); c.execute(\"INSERT OR IGNORE INTO cve_severity VALUES (?,?,?,?,?,?,?,datetime('now'))\", ('CVE-0000-00000','UNKNOWN','stub row for cve-bin-tool EmptyCache check; not used for matching',0,3,'','STUB')); c.commit(); c.close()"; \
    fi; \
    if [ -s "$CVE_BIN_TOOL_HOME/cpe_match.sqlite" ]; then \
      echo "[build] CPE index included ($(du -h "$CVE_BIN_TOOL_HOME/cpe_match.sqlite" | cut -f1))"; \
    else \
      echo "[build] CPE index skipped (SBOM_CPE_INDEX=false)"; \
    fi

# OWASP AIBOM Generator (opt-in: --build-arg SBOM_AIBOM=true). Generates an AI
# SBOM (CycloneDX 1.7) for a HuggingFace model id; see scan-aibom.sh.
# Apache-2.0, pinned to a commit (no PyPI
# release yet). Cloned into /opt/aibom-generator and run as `python -m src.cli`
# from there: a bare `pip install` drops the tool's data files (model-field
# registry, SPDX schema), so we keep the repo layout. git is in the base layer.
#
# The commit's requirements.txt pins exact versions, some with known
# vulnerabilities. apply-pip-overrides.sh swaps in the fixed versions listed in
# aibom-requirements-overrides.txt, and fails the build when a pin it expected
# has moved upstream (see both files).
COPY apply-pip-overrides.sh aibom-requirements-overrides.txt /tmp/aibom-overrides/
RUN if [ "$SBOM_AIBOM" = "true" ]; then \
      git clone --quiet https://github.com/GenAI-Security-Project/aibom-generator /opt/aibom-generator \
      && git -C /opt/aibom-generator checkout --quiet "${AIBOM_GENERATOR_REF}" \
      && sh /tmp/aibom-overrides/apply-pip-overrides.sh \
           /opt/aibom-generator/requirements.txt \
           /tmp/aibom-overrides/aibom-requirements-overrides.txt \
           > /tmp/aibom-overrides/requirements.txt \
      && pip3 install --no-cache-dir -r /tmp/aibom-overrides/requirements.txt \
      && ( cd /opt/aibom-generator && python3 -m src.cli --help >/dev/null ) \
      && case "$(uname -m)" in \
           x86_64)  NODE_ARCH="x64" ;; \
           aarch64) NODE_ARCH="arm64" ;; \
           *)       NODE_ARCH="x64" ;; \
         esac \
      && curl -sSfL "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" -o /tmp/node.tgz \
      && tar -C /usr/local --strip-components=1 -xzf /tmp/node.tgz "node-${NODE_VERSION}-linux-${NODE_ARCH}" \
      && rm /tmp/node.tgz \
      && npm install -g "npm@${NPM_VERSION}" \
      && node --version && npm --version \
      && npm install -g "@cdxgen/cdxgen@${CDXGEN_VERSION}" \
      && cdxgen --version >/dev/null; \
    else \
      echo "[build] aibom generator skipped (SBOM_AIBOM=false)"; \
    fi \
    && rm -rf /tmp/aibom-overrides

# NOTICE PDF renderer (opt-in: --build-arg SBOM_PDF=true). weasyprint (BSD-3-Clause)
# plus its native deps (libpango/cairo/gdk-pixbuf, ~50-60 MB). generate-notice.sh
# detects it via `command -v weasyprint` and skips the PDF step when it is absent,
# so the default image stays lean and a scan never fails for lack of the renderer.
RUN if [ "$SBOM_PDF" = "true" ]; then \
      apt-get update && apt-get install -y --no-install-recommends \
        libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libcairo2 libffi8 \
        fonts-dejavu-core \
      && pip3 install --no-cache-dir "weasyprint==${WEASYPRINT_VERSION}" \
      && rm -rf /var/lib/apt/lists/* \
      && weasyprint --version; \
    else \
      echo "[build] PDF renderer (weasyprint) skipped (SBOM_PDF=false)"; \
    fi

# Reflects the firmware build flavor. When SBOM_FIRMWARE=true the image bundles GPL
# tools (cve-bin-tool, ubi_reader); the source-offer label points to the inventory.
LABEL com.sktelecom.sbom.firmware-tools="${SBOM_FIRMWARE}" \
      com.sktelecom.sbom.cpe-index="${SBOM_CPE_INDEX}" \
      com.sktelecom.sbom.scanoss-tools="${SBOM_SCANOSS}" \
      com.sktelecom.sbom.aibom-tools="${SBOM_AIBOM}" \
      com.sktelecom.sbom.pdf-tools="${SBOM_PDF}" \
      com.sktelecom.sbom.deep-license-tools="${SBOM_DEEP_LICENSE}" \
      com.sktelecom.sbom.deep-cve-tools="${SBOM_DEEP_CVE}" \
      com.sktelecom.sbom.gpl-source-offer="https://github.com/sktelecom/bomlens/blob/main/THIRD_PARTY_LICENSES.md"

COPY entrypoint.sh /usr/local/bin/run-scan
COPY lib/ /usr/local/lib/sbom/
# Design tokens (generated from the web UI's index.css) that the HTML report
# generators inline into every report. Kept beside the scripts, where
# report-common.sh looks first; the build fails if it is missing or empty so an
# image never ships reports without the shared palette.
COPY shared/bomlens-tokens.css /usr/local/lib/sbom/bomlens-tokens.css
RUN test -s /usr/local/lib/sbom/bomlens-tokens.css \
    && grep -q -- '--role-danger-fg:' /usr/local/lib/sbom/bomlens-tokens.css \
    || { echo "bomlens-tokens.css is missing or incomplete: run 'npm run tokens:build' in docker/web/frontend" >&2; exit 1; }
COPY web/server.py /usr/local/lib/sbom-web/server.py
COPY --from=web /web/dist /usr/local/lib/sbom-web/dist
RUN chmod +x /usr/local/bin/run-scan /usr/local/lib/sbom/*.sh

# Bundle an endoflife.date snapshot beside enrich-eol.sh so component end-of-life
# flagging runs OFFLINE at scan time (no per-scan network, works air-gapped). The
# bundle holds only the products eol-purl-map.json references and is a few KB.
# Best-effort: if the fetch fails at build time no file is written and enrich-eol.sh
# skips cleanly at scan time (EOL flagging is optional, never a blocker). Attribution
# and snapshot date live in the bundle's _snapshot field. See build-eol-index.py.
COPY build-eol-index.py /tmp/build-eol-index.py
RUN timeout 120 python3 /tmp/build-eol-index.py \
        /usr/local/lib/sbom/eol-purl-map.json \
        /usr/local/lib/sbom/eol-data.json \
    || echo "[build] endoflife snapshot skipped; EOL flagging will be inactive at scan time" \
    ; rm -f /tmp/build-eol-index.py

# Bundle an OSV malicious-package snapshot beside enrich-malicious.sh, for the
# same reason as the EOL bundle above: the check must run OFFLINE at scan time.
# This one is far heavier to build than the EOL bundle: the source archives are
# ~275 MB, so it gets its own layer and a much larger timeout. The reduced
# index it writes is ~13 MB (238k packages, of which 30 carry a version range
# rather than an open verdict, measured 2026-09-13). Its own layer
# also means a rebuild that changes nothing else reuses the cached download
# instead of fetching it again, which matters on the multi-architecture publish
# where every stage would otherwise pay for it twice.
# Best-effort in the same way: if the fetch fails no file is written and
# enrich-malicious.sh skips cleanly at scan time. See build-malicious-index.py.
COPY build-malicious-index.py /tmp/build-malicious-index.py
RUN timeout 1200 python3 /tmp/build-malicious-index.py \
        /usr/local/lib/sbom/malicious-index.json \
    || echo "[build] OSV malicious snapshot skipped; the check will be inactive at scan time" \
    ; rm -f /tmp/build-malicious-index.py

WORKDIR /src

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD syft version >/dev/null 2>&1 && trivy --version >/dev/null 2>&1 || exit 1

ENTRYPOINT ["/usr/local/bin/run-scan"]
