From 8b659700d6388ae81ad47105d89ecf426ae1ab94 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 17 Jun 2026 10:18:17 +0900 Subject: [PATCH 1/3] feat(scanner): merge layered SBOMs with --merge Add a --merge mode that combines two or more CycloneDX SBOMs into one, dedupes components by purl, and stamps the root component with --project/--version. It is built for layered server delivery: an OS rootfs layer, an application layer, and a static-link layer are each scanned separately, then merged when a submission system needs a single product BOM. Each component keeps a bomlens:layer property so layer provenance survives the merge; dependencies trees are dropped (their bom-ref namespaces collide across inputs). merge-sbom.sh passes components between jq steps via files (jq -s / --slurpfile), not argv, so a real server SBOM with hundreds/thousands of components does not overflow ARG_MAX. Also stamp the ROOTFS root component: syft names a dir: scan after the scan path (/target), which is meaningless and leaks the container mount path into the SBOM. ROOTFS now gets the caller's --project/--version, the same fix stamp-metadata.sh already applied to cdxgen scans. --- docker/entrypoint.sh | 30 ++++++++-- docker/lib/merge-sbom.sh | 106 +++++++++++++++++++++++++++++++++++ docker/lib/stamp-metadata.sh | 7 ++- scripts/scan-sbom.sh | 60 +++++++++++++++++++- 4 files changed, 191 insertions(+), 12 deletions(-) create mode 100755 docker/lib/merge-sbom.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 73a6c30..178785a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,6 +25,7 @@ set -e # - IMAGE/BINARY/ROOTFS : syft scan -> SBOM # - FIRMWARE : unpack firmware -> syft + cve-bin-tool -> SBOM (opt-in image) # - ANALYZE : validate + convert a supplier SBOM -> conformance + risk report +# - MERGE : combine several CycloneDX SBOMs (layered server delivery) # - POSTPROCESS : consume an already-generated SBOM # then runs the common pipeline: normalize -> notice -> security -> sign. # ======================================================== @@ -171,6 +172,20 @@ EOF bash "$LIBDIR/scan-firmware.sh" "$TARGET_FILE" "$OUTPUT_FILE" "$PROJECT_VERSION" ;; + MERGE) + # Combine several already-generated CycloneDX SBOMs into one (e.g. a + # server's OS rootfs layer + application layer + static-link layer). + # MERGE_FILES is a space-separated list of container paths (read-only + # mounts set up by scan-sbom.sh). merge-sbom.sh writes its own root + # component from PROJECT_NAME/VERSION, so no stamp pass is needed below. + if [ -z "$MERGE_FILES" ]; then echo "[ERROR] MERGE_FILES required for MERGE mode."; exit 1; fi + echo "[1/2] Merging layered SBOMs -> $OUTPUT_FILE" + # shellcheck disable=SC2086 + if ! bash "$LIBDIR/merge-sbom.sh" "$OUTPUT_FILE" "$PROJECT_NAME" "$PROJECT_VERSION" $MERGE_FILES; then + echo "[ERROR] SBOM merge failed."; exit 1 + fi + ;; + POSTPROCESS) # SBOM was already generated by a cdxgen language image (scan-sbom.sh). echo "[1/2] Post-processing existing SBOM: $OUTPUT_FILE" @@ -197,7 +212,7 @@ EOF ;; *) - echo "[ERROR] Unknown MODE: $SCAN_MODE (expected SOURCE/IMAGE/BINARY/ROOTFS/FIRMWARE/ANALYZE/POSTPROCESS/UI)" + echo "[ERROR] Unknown MODE: $SCAN_MODE (expected SOURCE/IMAGE/BINARY/ROOTFS/FIRMWARE/ANALYZE/MERGE/POSTPROCESS/UI)" exit 1 ;; esac @@ -220,12 +235,15 @@ fi # ======================================================== ARTIFACTS=("$OUTPUT_FILE") -# Stamp the BOM's root component with the caller's --project/--version. Limited -# to the cdxgen-backed modes; IMAGE/BINARY/ROOTFS/FIRMWARE/ANALYZE carry their own -# meaningful root component (a basename, or a supplier's own identifier we must -# preserve). See stamp-metadata.sh for the rationale. +# Stamp the BOM's root component with the caller's --project/--version. ROOTFS is +# stamped too: syft names a `dir:` scan's root component after the scan path +# (/target), which is meaningless and leaks the container mount path — the same +# leak stamp-metadata.sh fixes for cdxgen. IMAGE/BINARY/FIRMWARE/ANALYZE/MERGE keep +# their own meaningful root (an image/file basename, a supplier's own identifier we +# must preserve, or — for MERGE — the project root merge-sbom.sh already wrote). +# See stamp-metadata.sh for the rationale. case "$SCAN_MODE" in - SOURCE|POSTPROCESS) + SOURCE|POSTPROCESS|ROOTFS) bash "$LIBDIR/stamp-metadata.sh" "$OUTPUT_FILE" "$PROJECT_NAME" "$PROJECT_VERSION" || true ;; esac diff --git a/docker/lib/merge-sbom.sh b/docker/lib/merge-sbom.sh new file mode 100755 index 0000000..631d3d4 --- /dev/null +++ b/docker/lib/merge-sbom.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Copyright 2026 SK Telecom Co., Ltd. +# Licensed under the Apache License, Version 2.0. +# +# merge-sbom.sh — merge several CycloneDX SBOMs into one. +# +# Usage: merge-sbom.sh [...] +# produces (CycloneDX 1.6) whose root component is the project, +# with every input's components flattened and deduped by purl. +# +# Built for layered server SBOMs: an OS rootfs layer, an application layer, and a +# static-link layer are each scanned separately, then merged here so the +# downstream pipeline (notice/security/risk-report) sees one component set. +# +# Each component keeps a `bomlens:layer` property naming the source layer (the +# input's root component name, or layer-), so provenance survives the +# merge. Dedup keeps the first occurrence, so the first layer listed wins. +# +# `dependencies` trees are NOT merged: bom-ref namespaces collide across inputs +# and the downstream pipeline only walks `.components[]`, so a merged tree would +# add risk without value. +set -e + +OUTPUT="$1" +NAME="$2" +VERSION="$3" +shift 3 2>/dev/null || true + +if [ -z "$OUTPUT" ] || [ -z "$NAME" ] || [ -z "$VERSION" ]; then + echo "[merge] usage: merge-sbom.sh [...]" >&2 + exit 1 +fi +if [ "$#" -lt 2 ]; then + echo "[merge] need at least 2 input SBOMs to merge (got $#)." >&2 + exit 1 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "[merge] ERROR: jq not installed in this image." >&2 + exit 1 +fi + +GEN_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Collect each input's components into a temp file, tagging each with its source +# layer. Drop components without a real name (syft's empty "os:unknown" noise). A +# malformed input is skipped with a warning rather than aborting the whole merge. +# Components are passed between jq steps via FILES, not argv — a real server SBOM +# has hundreds/thousands of components and `--argjson ` overflows +# ARG_MAX ("Argument list too long"). +i=0 +valid=0 +for f in "$@"; do + if [ ! -s "$f" ] || ! jq empty "$f" >/dev/null 2>&1; then + echo "[merge] WARN: skipping missing or invalid SBOM: $f" >&2 + i=$((i + 1)) + continue + fi + LAYER=$(jq -r '.metadata.component.name // empty' "$f" 2>/dev/null) + [ -n "$LAYER" ] || LAYER="layer-$i" + jq -c --arg L "$LAYER" ' + [ .components[]? + | select((.name // "") != "") + | .properties = ((.properties // []) + [{name: "bomlens:layer", value: $L}]) ]' \ + "$f" > "$WORK/comps-$i.json" + valid=$((valid + 1)) + i=$((i + 1)) +done + +if [ "$valid" -eq 0 ]; then + echo "[merge] ERROR: no valid CycloneDX inputs to merge." >&2 + exit 1 +fi + +# Merge all per-layer component files, dedupe by purl (fallback name@version). +# `jq -s` slurps each file as one array element; `add` concatenates them. +jq -s ' + add + | group_by(.purl // ((.name // "") + "@" + (.version // ""))) + | map(.[0]) + | sort_by(.purl // ((.name // "") + "@" + (.version // ""))) +' "$WORK"/comps-*.json > "$WORK/merged.json" + +NTOTAL=$(jq 'length' "$WORK/merged.json") + +# --slurpfile reads the merged components from a file (again, ARG_MAX safe). +jq -n \ + --slurpfile comps "$WORK/merged.json" \ + --arg name "$NAME" \ + --arg version "$VERSION" \ + --arg ts "$GEN_AT" ' +{ + bomFormat: "CycloneDX", + specVersion: "1.6", + version: 1, + metadata: { + timestamp: $ts, + tools: { components: [ { type: "application", name: "bomlens-merge" } ] }, + component: { type: "application", name: $name, version: $version } + }, + components: $comps[0] +}' > "$OUTPUT" + +echo "[merge] SBOM written: $OUTPUT (components=${NTOTAL} from ${valid} layer(s))" diff --git a/docker/lib/stamp-metadata.sh b/docker/lib/stamp-metadata.sh index c2268d8..73054a1 100755 --- a/docker/lib/stamp-metadata.sh +++ b/docker/lib/stamp-metadata.sh @@ -14,9 +14,10 @@ # root component name/version with the caller's --project/--version and drop the # now-stale purl (optional in CycloneDX) since it encoded the old coordinates. # -# Only meaningful for cdxgen-backed modes (SOURCE/POSTPROCESS); IMAGE/BINARY/ -# ROOTFS/FIRMWARE/ANALYZE carry their own meaningful root component and should -# not call this. +# Called for cdxgen-backed modes (SOURCE/POSTPROCESS) and for ROOTFS, where syft +# names the root component after the scan path (/target) — meaningless and leaking +# the mount path, the same problem described above. IMAGE/BINARY/FIRMWARE/ANALYZE +# carry their own meaningful root component and should not call this. set -e SBOM="$1" diff --git a/scripts/scan-sbom.sh b/scripts/scan-sbom.sh index 63fc5a6..583bd11 100755 --- a/scripts/scan-sbom.sh +++ b/scripts/scan-sbom.sh @@ -46,6 +46,7 @@ SIGN_SBOM="false"; BYTE_STABLE="false"; UI_MODE="false"; UI_PORT="${UI_PORT:-808 FORCE_FIRMWARE="false"; ANALYZE_SBOM="" GIT_URL=""; GIT_REF=""; NO_REPORT="false"; GENERATE_REPORT="false" INGEST_SOURCE="false"; SCAN_INPUT_DIR=""; CLEANUP_DIRS=() +MERGE_FILES=() # ======================================================== # Parse arguments @@ -56,6 +57,15 @@ while [[ "$#" -gt 0 ]]; do --version) PROJECT_VERSION="$2"; shift ;; --target) TARGET="$2"; shift ;; --analyze|--sbom) ANALYZE_SBOM="$2"; shift ;; + --merge) + # Variadic: absorb every following token until the next option (a + # token starting with '-'). These are already-generated SBOMs to + # combine, not scan targets, so they get their own flag. + shift + while [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; do + MERGE_FILES+=("$1"); shift + done + continue ;; # we already consumed our args; skip the trailing shift --git) GIT_URL="$2"; shift ;; --branch|--ref) GIT_REF="$2"; shift ;; --no-report) NO_REPORT="true" ;; @@ -86,6 +96,11 @@ Options: --firmware Force firmware mode for --target file (opt-in image) --analyze Validate + analyze a supplier SBOM (alias: --sbom). CycloneDX or SPDX; mutually exclusive with --target. + --merge [...] + Merge 2+ CycloneDX SBOMs into one, dedupe by purl, and + stamp the root component with --project/--version. For + layered server delivery (OS rootfs + app + static-link). + Mutually exclusive with --target/--analyze/--git. --generate-only Save locally without uploading --trusca Upload the SBOM to TRUSCA's native ingest endpoint (shorthand for --upload-target trusca with the id). @@ -221,6 +236,21 @@ is_git_url() { [[ "$1" =~ ^(https?://|git@|ssh://git@|file://)[A-Za-z0-9._~:@/+-]+$ ]] } +# A CycloneDX SBOM we accept as a --merge input. Anti-injection: reject '-' +# prefixes and '..' traversal. When the host has jq, also verify it is CycloneDX; +# without jq, existence is enough (the container re-validates during the merge). +is_sbom_file() { + case "$1" in + -*|*..*) return 1 ;; + esac + [ -f "$1" ] || return 1 + if command -v jq >/dev/null 2>&1; then + jq -e '.bomFormat == "CycloneDX"' "$1" >/dev/null 2>&1 + else + return 0 + fi +} + # A source archive we auto-extract and scan as SOURCE. is_archive() { case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in @@ -321,7 +351,18 @@ elif [ -n "$TARGET" ] && [ -f "$TARGET" ] && is_archive "$TARGET"; then fi MODE="SOURCE" -if [ "$INGEST_SOURCE" = "true" ]; then +if [ "${#MERGE_FILES[@]}" -gt 0 ]; then + # Merge several already-generated SBOMs. Exclusive with every scan input. + [ -z "$TARGET" ] || { echo "[ERROR] --merge is mutually exclusive with --target."; exit 1; } + [ -z "$ANALYZE_SBOM" ] || { echo "[ERROR] --merge is mutually exclusive with --analyze."; exit 1; } + [ -z "$GIT_URL" ] || { echo "[ERROR] --merge is mutually exclusive with --git."; exit 1; } + [ "$FORCE_FIRMWARE" != "true" ] || { echo "[ERROR] --merge cannot be combined with --firmware."; exit 1; } + [ "${#MERGE_FILES[@]}" -ge 2 ] || { echo "[ERROR] --merge needs at least 2 SBOM files."; exit 1; } + for mf in "${MERGE_FILES[@]}"; do + is_sbom_file "$mf" || { echo "[ERROR] not a CycloneDX SBOM (or unsafe path): $mf"; exit 1; } + done + MODE="MERGE" +elif [ "$INGEST_SOURCE" = "true" ]; then # A git clone / extracted archive is always scanned as SOURCE (the temp dir # would otherwise be detected as ROOTFS below). MODE="SOURCE" @@ -416,8 +457,9 @@ if [ "$MODE" = "SOURCE" ]; then -e MODE=POSTPROCESS $(pp_env)$(cosign_run) \ "\"$POSTPROCESS_IMAGE\"" else - # image / binary / rootfs / firmware: scanner image runs syft + pipeline in one shot. - # Firmware needs the heavier opt-in image (unblob/cve-bin-tool); others use the base image. + # image / binary / rootfs / firmware / analyze / merge: scanner image runs the + # syft (or merge/convert) step + common pipeline in one shot. Firmware needs + # the heavier opt-in image (unblob/cve-bin-tool); others use the base image. VOL=""; ENVV=""; RUN_IMAGE="$POSTPROCESS_IMAGE" case "$MODE" in IMAGE) VOL="-v \"$SOURCE_DIR\":/host-output -v /var/run/docker.sock:/var/run/docker.sock"; ENVV="-e TARGET_IMAGE=\"$TARGET\"" ;; @@ -425,6 +467,18 @@ else ROOTFS) TD="$(cd "$TARGET" && pwd)"; VOL="-v \"$TD\":/target -v \"$SOURCE_DIR\":/host-output"; ENVV="-e TARGET_DIR=/target" ;; FIRMWARE) FD="$(cd "$(dirname "$TARGET")" && pwd)"; FN="$(basename "$TARGET")"; VOL="-v \"$FD\":/target -v \"$SOURCE_DIR\":/host-output"; ENVV="-e TARGET_FILE=\"/target/$FN\""; RUN_IMAGE="$FIRMWARE_IMAGE" ;; ANALYZE) FD="$(cd "$(dirname "$ANALYZE_SBOM")" && pwd)"; FN="$(basename "$ANALYZE_SBOM")"; VOL="-v \"$FD\":/input:ro -v \"$SOURCE_DIR\":/host-output"; ENVV="-e ANALYZE_SBOM=\"/input/$FN\"" ;; + MERGE) + # Mount each input's directory read-only under its own index so files + # that share a basename (three layers all named *_bom.json) don't + # collide. MERGE_FILES carries the container-side paths. + VOL="-v \"$SOURCE_DIR\":/host-output"; MF_CONTAINER=""; i=0 + for mf in "${MERGE_FILES[@]}"; do + FD="$(cd "$(dirname "$mf")" && pwd)"; FN="$(basename "$mf")" + VOL="$VOL -v \"$FD\":/merge-in-$i:ro" + MF_CONTAINER="$MF_CONTAINER /merge-in-$i/$FN" + i=$((i + 1)) + done + ENVV="-e MERGE_FILES=\"${MF_CONTAINER# }\"" ;; esac eval docker run --rm $VOL \ --add-host=host.docker.internal:host-gateway \ From 23016d0a06cd6bcb8e94f564036b174a9a7601d4 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 17 Jun 2026 10:18:25 +0900 Subject: [PATCH 2/3] feat(ui): scan an arbitrary rootfs directory Add a "Directory path" (rootfs-dir) input to the web UI so an OS rootfs or any subfolder under the launch folder can be scanned as a directory (MODE=ROOTFS), not just the fixed current folder. safe_scan_dir() resolves the user path with realpath and forces it inside /src (the only mounted root), rejecting ../ traversal, absolute paths, and symlink escapes. The allowed-root boundary is a list, so a future `--ui --mount ` can extend it without touching the check. --- .../src/components/InputTypeSelector.tsx | 1 + .../web/frontend/src/components/ScanForm.tsx | 40 +++++++++++++------ docker/web/frontend/src/lib/api.ts | 2 + .../web/frontend/src/locales/en/common.json | 3 ++ .../web/frontend/src/locales/ko/common.json | 3 ++ docker/web/server.py | 37 +++++++++++++++++ 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/docker/web/frontend/src/components/InputTypeSelector.tsx b/docker/web/frontend/src/components/InputTypeSelector.tsx index 9b90512..6ad9189 100644 --- a/docker/web/frontend/src/components/InputTypeSelector.tsx +++ b/docker/web/frontend/src/components/InputTypeSelector.tsx @@ -13,6 +13,7 @@ interface Props { const LABEL_KEY: Record = { "current-dir": "source.currentDir", + "rootfs-dir": "source.rootfsDir", "git-url": "source.gitUrl", "zip-upload": "source.zipUpload", "sbom-upload": "source.sbomUpload", diff --git a/docker/web/frontend/src/components/ScanForm.tsx b/docker/web/frontend/src/components/ScanForm.tsx index 5068fe0..20a68c4 100644 --- a/docker/web/frontend/src/components/ScanForm.tsx +++ b/docker/web/frontend/src/components/ScanForm.tsx @@ -42,6 +42,27 @@ const ACCEPT: Record = { firmware: ".bin,.img,.squashfs,.sqsh,.ubi,.ubifs,.trx,.chk,.fw,.rom,.dlf", }; +// Free-text inputs: the single `target` field, with per-source i18n keys. +const TEXT_INPUT: Partial< + Record +> = { + "git-url": { + label: "source.gitUrl", + placeholder: "source.gitPlaceholder", + hint: "source.gitHint", + }, + "docker-image": { + label: "source.dockerImage", + placeholder: "source.dockerPlaceholder", + hint: "source.dockerHint", + }, + "rootfs-dir": { + label: "source.rootfsDir", + placeholder: "source.rootfsPlaceholder", + hint: "source.rootfsHint", + }, +}; + export function ScanForm({ running, capabilities, onRun }: Props) { const { t } = useTranslation(); const [project, setProject] = useState(""); @@ -58,7 +79,8 @@ export function ScanForm({ running, capabilities, onRun }: Props) { const [uploading, setUploading] = useState(false); const uploadKind = UPLOAD_KIND[source]; - const isText = source === "git-url" || source === "docker-image"; + const textInput = TEXT_INPUT[source]; + const isText = textInput !== undefined; const isAnalyze = source === "sbom-upload"; const busy = running || uploading; @@ -193,25 +215,17 @@ export function ScanForm({ running, capabilities, onRun }: Props) { )} - {isText && ( + {textInput && (
- + setTarget(e.target.value)} - placeholder={ - source === "git-url" - ? t("source.gitPlaceholder") - : t("source.dockerPlaceholder") - } + placeholder={t(textInput.placeholder)} disabled={busy} /> -

- {source === "git-url" ? t("source.gitHint") : t("source.dockerHint")} -

+

{t(textInput.hint)}

)} diff --git a/docker/web/frontend/src/lib/api.ts b/docker/web/frontend/src/lib/api.ts index f24ffe7..8169113 100644 --- a/docker/web/frontend/src/lib/api.ts +++ b/docker/web/frontend/src/lib/api.ts @@ -81,6 +81,7 @@ export interface DoneEvent { /** Input types the UI offers; each maps to a backend MODE in server.py. */ export type SourceType = | "current-dir" + | "rootfs-dir" | "git-url" | "zip-upload" | "sbom-upload" @@ -89,6 +90,7 @@ export type SourceType = export const SOURCE_TYPES: SourceType[] = [ "current-dir", + "rootfs-dir", "git-url", "zip-upload", "sbom-upload", diff --git a/docker/web/frontend/src/locales/en/common.json b/docker/web/frontend/src/locales/en/common.json index 2a03858..3eeec1b 100644 --- a/docker/web/frontend/src/locales/en/common.json +++ b/docker/web/frontend/src/locales/en/common.json @@ -18,6 +18,9 @@ "currentDir": "Current folder", "currentDirPath": "Current folder", "currentDirHint": "Scans the source in this folder (mounted as /src). To scan a different folder, run --ui from there.", + "rootfsDir": "Directory path", + "rootfsPlaceholder": "centos-rootfs/ (relative to the current folder)", + "rootfsHint": "Scans an OS rootfs or any subfolder under the current folder (/src) as a directory. Paths outside the folder are rejected — place the rootfs under the launch folder, or use a Docker image instead.", "gitUrl": "GitHub URL", "gitHint": "Public repos clone directly; for private repos enter a token below.", "gitPlaceholder": "https://github.com/org/repo", diff --git a/docker/web/frontend/src/locales/ko/common.json b/docker/web/frontend/src/locales/ko/common.json index bbade60..0a90950 100644 --- a/docker/web/frontend/src/locales/ko/common.json +++ b/docker/web/frontend/src/locales/ko/common.json @@ -18,6 +18,9 @@ "currentDir": "현재 폴더", "currentDirPath": "현재 폴더", "currentDirHint": "이 폴더(컨테이너에 /src로 마운트)의 소스를 스캔합니다. 다른 폴더를 스캔하려면 그 폴더에서 --ui를 실행하세요.", + "rootfsDir": "디렉터리 경로", + "rootfsPlaceholder": "centos-rootfs/ (현재 폴더 기준 상대경로)", + "rootfsHint": "현재 폴더(/src) 하위의 OS rootfs나 임의 하위 폴더를 디렉터리로 스캔합니다. 폴더 밖 경로는 거부되므로, rootfs를 실행 폴더 하위에 두거나 Docker 이미지 입력을 사용하세요.", "gitUrl": "GitHub URL", "gitHint": "공개 저장소는 그대로, 비공개는 아래 토큰을 입력하세요.", "gitPlaceholder": "https://github.com/org/repo", diff --git a/docker/web/server.py b/docker/web/server.py index 2547ffb..d4d6580 100644 --- a/docker/web/server.py +++ b/docker/web/server.py @@ -14,6 +14,7 @@ # # Input types (the `source` query param on /scan-stream): # current-dir -> MODE=SOURCE (syft dir scan of /src) +# rootfs-dir -> MODE=ROOTFS (syft dir scan of , a subfolder of /src) # git-url -> clone then MODE=SOURCE # zip-upload -> extract uploaded zip then MODE=SOURCE # sbom-upload -> MODE=ANALYZE on the uploaded SBOM @@ -96,6 +97,30 @@ def safe_output_path(name): return path +# Directories the UI is allowed to scan as a ROOTFS target. Only /src is mounted +# into the UI container today; a future `--ui --mount ` would append +# its container path here, and the boundary check below extends to it for free. +ALLOWED_SCAN_ROOTS = [SRC_DIR] + + +def safe_scan_dir(rel): + """Resolve a user-supplied directory path strictly inside an allowed scan + root (block path traversal and symlink escape). Returns the real path on + success, or None. Used by the rootfs-dir input — a relative path under /src. + """ + if not rel or any(c in rel for c in ("\x00", "\n", "\r")): + return None + # Treat input as relative to /src: stripping any leading '/' folds an + # absolute path like /etc back under /src, so it can't escape the boundary. + rel = rel.lstrip("/") + real = os.path.realpath(os.path.join(SRC_DIR, rel)) + for root in ALLOWED_SCAN_ROOTS: + r = os.path.realpath(root) + if (real == r or real.startswith(r + os.sep)) and os.path.isdir(real): + return real + return None + + def firmware_capable(): """The firmware tools (unblob) are only built into the firmware image.""" return shutil.which("unblob") is not None @@ -629,6 +654,18 @@ def fail(msg): env["MODE"] = "SOURCE" env["SOURCE_ROOT"] = SRC_DIR + elif source == "rootfs-dir": + # Scan an OS rootfs (or any subfolder) under /src as a directory. + # The path is validated to stay inside the mounted folder so it + # can't reach /host-output uploads or container system paths. + scan_dir = safe_scan_dir(target) + if not scan_dir: + fail("Invalid or out-of-bounds directory path " + "(must be a folder inside the current folder)"); return + mode = "ROOTFS" + env["MODE"] = "ROOTFS" + env["TARGET_DIR"] = scan_dir + elif source == "git-url": if not target: fail("Git URL required"); return From 3564eae3c7a2608fff6efb0067e651d61f637ede Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 17 Jun 2026 10:18:32 +0900 Subject: [PATCH 3/3] docs: add server delivery guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document how a supplier builds a server SBOM: scan the OS rootfs, the application, and the static-link dependencies as three layers, verify each, and submit them separately. Submitting the layers separately is the default — it keeps each layer reviewable and preserves per-layer dependency graphs. Merging into one product BOM with --merge is an optional step for when a submission system requires a single file. Also note the static-link detection limits (binary/firmware mode plus hand-recorded build sources, with BDBA as SKT's complementary check) and the rejection causes (hand-written SBOMs, pkg:generic, raw-directory scans, scanning before the build). Document --merge in the CLI reference and expand --target to mention rootfs/staging targets. --- docs/guides/by-input.ko.md | 2 +- docs/guides/by-input.md | 2 +- docs/guides/server-delivery.ko.md | 120 ++++++++++++++++++++++++++++++ docs/guides/server-delivery.md | 120 ++++++++++++++++++++++++++++++ docs/reference/cli.ko.md | 3 +- docs/reference/cli.md | 3 +- mkdocs.yml | 1 + 7 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 docs/guides/server-delivery.ko.md create mode 100644 docs/guides/server-delivery.md diff --git a/docs/guides/by-input.ko.md b/docs/guides/by-input.ko.md index 404fc18..909f637 100644 --- a/docs/guides/by-input.ko.md +++ b/docs/guides/by-input.ko.md @@ -89,7 +89,7 @@ $SBOM --project team3-dev --version 1.0.0 --all --deep-license --generate-only **C/C++ 안내** - 패키지 매니저가 있으면(Conan `conanfile.txt` / vcpkg `vcpkg.json`) 의존성이 해석되어 SBOM에 반영됩니다. -- 순수 CMake/Make 소스는 매니저 메타데이터가 없어 SBOM이 희소할 수 있습니다. 이때는 `--deep-license`로 1st-party 소스의 라이선스 헤더를 보강하고, 빌드 산출물(설치된 라이브러리가 있는 staging/rootfs)은 별도로 `$SBOM --target --all --generate-only`(syft)로 분석합니다. +- 순수 CMake/Make 소스는 매니저 메타데이터가 없어 SBOM이 희소할 수 있습니다. 이때는 `--deep-license`로 1st-party 소스의 라이선스 헤더를 보강하고, 빌드 산출물(설치된 라이브러리가 있는 staging/rootfs)은 별도로 `$SBOM --target --all --generate-only`(syft)로 분석합니다. OS rootfs와 애플리케이션, 정적 링크 의존성을 층별로 나눠 만드는 서버 납품 전체 흐름은 [서버 납품 가이드](server-delivery.md)를 참고하세요. - 패키지 매니저가 없어도 위험분석보고서는 생성되며, 탐지된 구성요소의 라이선스와 취약점을 집계합니다. **산출물**: 고지문, SBOM, 위험분석보고서 (3종) diff --git a/docs/guides/by-input.md b/docs/guides/by-input.md index f51ec21..b1e5782 100644 --- a/docs/guides/by-input.md +++ b/docs/guides/by-input.md @@ -89,7 +89,7 @@ $SBOM --project team3-dev --version 1.0.0 --all --deep-license --generate-only **C/C++ notes** - With a package manager (Conan `conanfile.txt` / vcpkg `vcpkg.json`), dependencies resolve and appear in the SBOM. -- Pure CMake/Make sources have no manager metadata, so the SBOM can be sparse. Enrich first-party license headers with `--deep-license`, and analyze build output (a staging/rootfs with installed libraries) separately with `$SBOM --target --all --generate-only` (syft). +- Pure CMake/Make sources have no manager metadata, so the SBOM can be sparse. Enrich first-party license headers with `--deep-license`, and analyze build output (a staging/rootfs with installed libraries) separately with `$SBOM --target --all --generate-only` (syft). For a full server delivery — OS rootfs, application, and static-link dependencies as separate layers — see the [server delivery guide](server-delivery.md). - Even without a package manager, the risk report is still generated, aggregating licenses and vulnerabilities of detected components. **Deliverables**: notice, SBOM, risk report (three) diff --git a/docs/guides/server-delivery.ko.md b/docs/guides/server-delivery.ko.md new file mode 100644 index 0000000..34de6a4 --- /dev/null +++ b/docs/guides/server-delivery.ko.md @@ -0,0 +1,120 @@ +--- +description: 공급사가 납품 서버의 SBOM을 만드는 방법. OS rootfs, 애플리케이션, 정적 링크 의존성을 층별로 따로 스캔해 제출하고, 제출 시스템이 단일 BOM을 요구할 때만 병합한다. +--- + +# 서버 납품 가이드 + +## 개요 + +납품 서버는 단일 소스 트리가 아닙니다. 운영체제가 있고, 그 위에 설치된 애플리케이션이 있으며, 빌드 과정에서 바이너리에 링크된 라이브러리가 있습니다. 이 중 하나만 스캔하면 나머지가 빠지고, 이것이 서버 SBOM이 반려되는 흔한 원인입니다. + +이 가이드는 서버를 세 층으로 보고 각 층을 BomLens로 스캔합니다. 세 SBOM을 따로 제출하는 것이 기본이며, 그래야 각 층을 그 자체로 검토할 수 있습니다. 제출 시스템이 단일 파일을 요구할 때만 하나의 제품 SBOM으로 병합합니다([선택: 단일 SBOM으로 병합](#선택-단일-sbom으로-병합) 참고). + +| 층 | 대상 | 누락 시 증상 | +|----|------|--------------| +| OS | 운영체제와 설치된 패키지 전체 (예: CentOS와 rpm 데이터베이스의 모든 패키지) | OS 취약점 누락 | +| 애플리케이션 | 납품 애플리케이션과 패키지 매니저 의존성(직접·전이) | 앱 의존성 누락 | +| 정적 링크 | 정적 링크되거나 수동으로 빌드된 라이브러리 (예: 정적 링크된 openssl, liblfds) | 가장 흔한 반려 원인 | + +세 층 모두 BomLens 하나로 만듭니다. 층마다 입력만 바꾸면 됩니다. 요구사항은 세 층을 모두 도구로 생성하는 것이지, 하나의 파일로 합치는 것이 아닙니다. + +## 공통 준비 + +> **Windows**: 아래 명령은 macOS/Linux 기준입니다. `scan-sbom.bat`와 WSL2 사용법은 [시작 가이드](../start/first-scan.md#installation)를 참고하세요. + +```bash +# Docker 20.10+ 필요. 스캐너 이미지를 한 번 받습니다. +docker pull ghcr.io/sktelecom/bomlens:latest + +# 스크립트 경로를 변수에 둡니다. +SBOM=/path/to/sbom-tools/scripts/scan-sbom.sh +``` + +## 1층 — OS 패키지 + +서버의 rootfs(추출한 루트 파일시스템)나 그 컨테이너 이미지를 스캔합니다. Syft가 rpm/dpkg/apk 데이터베이스를 읽어 설치된 패키지를 모두 실제 purl(`pkg:rpm/...`)로 기록합니다. + +```bash +# rootfs 디렉터리를 대상으로: +$SBOM --project mms-relay-os --version 6.10 \ + --target /path/to/server-rootfs \ + --all --generate-only + +# 서버가 컨테이너 이미지로 패키징돼 있다면: +$SBOM --project mms-relay-os --version 6.10 \ + --target mms-relay:6.10 \ + --all --generate-only +``` + +대상에는 패키지 데이터베이스가 들어 있어야 합니다. 설치 파일만 풀어 놓고 rpm 데이터베이스가 없는 폴더를 스캔하면 purl이 비어 반려됩니다. 실제 rootfs나 이미지를 대상으로 하세요. + +## 2층 — 애플리케이션 코드와 의존성 + +빌드를 마친 뒤 애플리케이션 소스를 스캔합니다. 패키지 매니저(Maven, npm, pip, Go modules, Conan 등)를 쓰면 전이 의존성까지 자동으로 해석됩니다. + +```bash +cd /path/to/app-source +$SBOM --project mms-relay-app --version 2.0.0 --all --generate-only +``` + +빌드를 먼저 하세요. 빌드나 설치 전 상태에서 스캔하면 전이 의존성이 해석되지 않습니다. 매니페스트가 없는 순수 CMake/Make 애플리케이션은 컴포넌트 목록이 희소해지므로, `--deep-license`로 자체 소스의 라이선스를 보강합니다. + +## 3층 — 정적 링크 의존성 + +소스 스캐너는 바이너리에 정적 링크된 라이브러리를 보지 못하며, 바로 이 지점이 이 층이 필요한 이유입니다. 완전 자동 경로가 없으므로 두 가지를 함께 씁니다. + +도구가 찾을 수 있는 만큼은 납품 바이너리나 펌웨어 이미지를 분석해 잡습니다. + +```bash +$SBOM --project mms-relay-bin --version 2.0.0 \ + --target /path/to/delivered-binary \ + --all --generate-only +``` + +스캔으로도 빠지는 부분은 빌드 스크립트에서 소스와 버전을 직접 기재합니다. 예를 들어 빌드가 가져오는 openssl 버전(`openssl 1.1.1za`)을 적습니다. 정적 링크 구성요소를 정밀하게 식별하는 것은 바이너리 구성 분석(BDBA)의 몫이며, SKT가 보완 검증으로 수행하므로 공급사가 이 부담을 혼자 지지는 않습니다. + +## 제출 전 층별 자가 검증 + +세 SBOM을 그대로 제출합니다. 합친 파일이 아니라 각 SBOM을 따로 확인해, 문제를 그 층에서 바로 잡습니다. 각 SBOM이 올바른 형식이고 컴포넌트가 실제 purl을 갖는지 봅니다. + +```bash +for bom in mms-relay-os_6.10_bom.json mms-relay-app_2.0.0_bom.json mms-relay-bin_2.0.0_bom.json; do + echo "$bom: $(jq '.components | length' "$bom") 컴포넌트, \ +$(jq '[.components[] | select(.purl)] | length' "$bom") purl 보유" +done +``` + +각 층에서 두 값은 비슷해야 합니다. 차이가 크면 purl 없는 컴포넌트가 많다는 뜻이고, 보통 원시 디렉터리 스캔이나 수기 작성이 원인입니다. 그다음 [CycloneDX validator](https://github.com/CycloneDX/cyclonedx-cli)로 스키마 유효성을 확인합니다. + +층을 분리해 두는 것이 기본인 이유가 있습니다. 검토자가 어느 층이 빠졌는지, 취약점이 어디 있는지 한눈에 보고, 각 SBOM이 자체 의존성 그래프(`dependencies`)를 그대로 유지하기 때문입니다. + +## 선택: 단일 SBOM으로 병합 + +제출이나 업로드 시스템이 제품당 단일 BOM을 요구할 때만 병합합니다(Dependency-Track과 TRUSCA 모두 프로젝트당 BOM 하나를 등록합니다). `--merge`는 층을 합치고 purl 기준으로 컴포넌트 중복을 제거한 뒤, 최상위 컴포넌트를 납품 제품명·버전으로 기재합니다. + +```bash +$SBOM --project mms-relay-server --version 1.0.0 \ + --merge mms-relay-os_6.10_bom.json \ + mms-relay-app_2.0.0_bom.json \ + mms-relay-bin_2.0.0_bom.json \ + --generate-only +``` + +이 명령은 `mms-relay-server_1.0.0_bom.json`을 만들고, `metadata.component`를 서버 제품으로 설정하며, 병합된 컴포넌트 집합 위에 고지문과 위험분석보고서를 생성합니다. 각 컴포넌트에는 `bomlens:layer` 속성이 남으므로 층별로 걸러 볼 수 있습니다(`jq '.components[] | select(.properties[]?.value == "centos")'`). + +한 가지 절충이 있습니다. 병합은 층별 `dependencies` 트리를 버립니다(`bom-ref` 네임스페이스가 충돌하기 때문). 전이 의존성 그래프가 검토에 중요하면 층을 분리해 제출하세요. + +## 서버 SBOM이 반려되는 경우 + +- **수기 작성 SBOM.** `tool: manual` SBOM은 거의 항상 컴포넌트가 누락됩니다. 반드시 도구로 생성하세요. +- **`pkg:generic` 컴포넌트.** 취약점 매칭이 되도록 표준 purl 타입(`pkg:rpm`, `pkg:maven` 등)을 쓰세요. +- **메타데이터 없는 원시 디렉터리 스캔.** 패키지 데이터베이스 없이 설치 파일만 풀어 놓은 폴더를 스캔하면 purl이 비어 전체가 반려됩니다. 실제 rootfs나 이미지를 대상으로 하세요. +- **빌드 전 스캔.** 빌드 전 소스로 SBOM을 만들면 전이 의존성이 빠집니다. + +## 웹 UI 사용 + +OS층과 애플리케이션층은 웹 UI(`$SBOM --ui`)에서도 실행할 수 있습니다. UI를 실행한 폴더 하위에 rootfs를 두고 **디렉터리 경로** 입력을 쓰거나, 컨테이너 이미지를 **Docker 이미지** 입력으로 스캔합니다. 실행 폴더 밖 경로는 안전을 위해 거부되므로, 정적 링크층과 선택적 병합은 CLI에서 다루는 것이 가장 직접적입니다. + +--- + +> **관련**: [입력 시나리오](by-input.md) | [펌웨어 분석](firmware.md) | [받은 SBOM 검증](supplier-sbom.md) | [CLI 레퍼런스](../reference/cli.md) diff --git a/docs/guides/server-delivery.md b/docs/guides/server-delivery.md new file mode 100644 index 0000000..43b59c4 --- /dev/null +++ b/docs/guides/server-delivery.md @@ -0,0 +1,120 @@ +--- +description: How a supplier builds an SBOM for a delivered server — scan the OS rootfs, the application, and the static-link dependencies as separate layers and submit them separately, merging into one BOM only when the submission system requires it. +--- + +# Server delivery guide + +## Overview + +A delivered server is not a single source tree. It is an operating system, the application installed on top of it, and libraries that were linked into the binaries during the build. A scan of any one of these misses the others, which is the usual reason a server SBOM is rejected. + +This guide treats a server as three layers and scans each one with BomLens. Submit the three SBOMs separately — that is the default, and it keeps each layer reviewable on its own. Merge them into one product SBOM only when a submission system asks for a single file (see [Optional: merge into one SBOM](#optional-merge-into-one-sbom)). + +| Layer | What it covers | Symptom if omitted | +|-------|----------------|--------------------| +| OS | The OS and its installed packages (e.g. CentOS plus everything in the rpm database) | OS vulnerabilities missing | +| Application | The delivered application and its package-manager dependencies, direct and transitive | Application dependencies missing | +| Static-link | Libraries statically linked or built by hand (e.g. a statically linked openssl, liblfds) | The most common rejection cause | + +One tool, BomLens, produces all three. Only the input changes per layer. The requirement is that all three layers are generated with a tool — not that they end up in one file. + +## Common setup + +> **Windows**: the commands here are for macOS/Linux. See [Getting started](../start/first-scan.md#installation) for the `scan-sbom.bat` and WSL2 equivalents. + +```bash +# Docker 20.10+ required. Pull the scanner image once. +docker pull ghcr.io/sktelecom/bomlens:latest + +# Keep the script path in a variable. +SBOM=/path/to/sbom-tools/scripts/scan-sbom.sh +``` + +## Layer 1 — OS packages + +Scan the server's rootfs (the extracted root filesystem) or a container image of it. Syft reads the rpm/dpkg/apk database and records every installed package with a real purl (`pkg:rpm/...`). + +```bash +# A rootfs directory: +$SBOM --project mms-relay-os --version 6.10 \ + --target /path/to/server-rootfs \ + --all --generate-only + +# Or, if the server is packaged as a container image: +$SBOM --project mms-relay-os --version 6.10 \ + --target mms-relay:6.10 \ + --all --generate-only +``` + +The target must contain the package database. A folder holding only unpacked install files, with no rpm database, yields empty purls and is rejected. Use the real rootfs or image. + +## Layer 2 — Application code and dependencies + +Scan the application source after the build. With a package manager (Maven, npm, pip, Go modules, Conan, and others), transitive dependencies resolve automatically. + +```bash +cd /path/to/app-source +$SBOM --project mms-relay-app --version 2.0.0 --all --generate-only +``` + +Build first. Scanning before the build or install leaves transitive dependencies unresolved. For a pure CMake/Make application with no manifest, the component list is sparse; add `--deep-license` to record the first-party source licenses. + +## Layer 3 — Static-link dependencies + +Source scanners do not see libraries that were statically linked into a binary, which is exactly where this layer matters. There is no fully automatic path, so combine two approaches. + +Analyze the delivered binary or firmware image to catch what tooling can find: + +```bash +$SBOM --project mms-relay-bin --version 2.0.0 \ + --target /path/to/delivered-binary \ + --all --generate-only +``` + +For what the scan still misses, record the source and version by hand from the build script — for example the openssl release the build pulls in (`openssl 1.1.1za`). A precise inventory of statically linked components comes from binary composition analysis (BDBA); SKT runs that as a complementary check, so the supplier does not carry it alone. + +## Verify each layer before submitting + +Submit the three SBOMs as they are. Check each one — not a combined file — so a gap is caught in the layer it belongs to. Confirm it is well formed and that its components carry real purls. + +```bash +for bom in mms-relay-os_6.10_bom.json mms-relay-app_2.0.0_bom.json mms-relay-bin_2.0.0_bom.json; do + echo "$bom: $(jq '.components | length' "$bom") components, \ +$(jq '[.components[] | select(.purl)] | length' "$bom") with purl" +done +``` + +The two counts should be close for each layer. A large gap means many components lack a purl, which usually points to a raw-directory scan or a hand-written entry. Then validate the schema with the [CycloneDX validator](https://github.com/CycloneDX/cyclonedx-cli). + +Keeping the layers separate is the default for a reason: a reviewer sees at a glance which layer is missing or where a vulnerability sits, and each SBOM keeps its own dependency graph (`dependencies`). + +## Optional: merge into one SBOM + +Merge only when the submission or upload system expects a single product BOM (Dependency-Track and TRUSCA both register one BOM per project). `--merge` combines the layers, dedupes components by purl, and stamps the top-level component with the delivered product name and version. + +```bash +$SBOM --project mms-relay-server --version 1.0.0 \ + --merge mms-relay-os_6.10_bom.json \ + mms-relay-app_2.0.0_bom.json \ + mms-relay-bin_2.0.0_bom.json \ + --generate-only +``` + +This writes `mms-relay-server_1.0.0_bom.json` with `metadata.component` set to the server product, plus the notice and risk report over the merged set. Each component keeps a `bomlens:layer` property, so you can still filter by layer (`jq '.components[] | select(.properties[]?.value == "centos")'`). + +One trade-off: the merge drops the per-layer `dependencies` trees (their `bom-ref` namespaces collide). If the transitive-dependency graph matters for review, submit the layers separately instead. + +## What gets a server SBOM rejected + +- **Hand-written SBOMs.** A `tool: manual` SBOM almost always omits components. Always generate with a tool. +- **`pkg:generic` components.** Use the standard purl types (`pkg:rpm`, `pkg:maven`, and so on) so vulnerability matching works. +- **Raw-directory scans with no metadata.** Scanning a folder of unpacked install files, with no package database, leaves purls empty and the whole SBOM is rejected. Target a real rootfs or image. +- **Scanning before the build.** Building the SBOM from a pre-build source tree drops transitive dependencies. + +## Using the web UI + +The OS and application layers can also run from the web UI (`$SBOM --ui`). Put the rootfs under the folder where you launch the UI and use the **Directory path** input, or scan a container image with the **Docker image** input. Paths outside the launch folder are rejected for safety, so the static-link layer and the optional merge are most direct from the CLI. + +--- + +> **Related**: [Input scenarios](by-input.md) | [Firmware analysis](firmware.md) | [Validating a received SBOM](supplier-sbom.md) | [CLI reference](../reference/cli.md) diff --git a/docs/reference/cli.ko.md b/docs/reference/cli.ko.md index 6592679..d3e1b9f 100644 --- a/docs/reference/cli.ko.md +++ b/docs/reference/cli.ko.md @@ -22,11 +22,12 @@ BomLens의 전체 옵션과 분석 모드, CI/CD 통합 방법, 트러블슈팅 |------|--------|------| | `--project <이름>` | — | **(필수)** 프로젝트 이름 | | `--version <버전>` | — | **(필수)** 프로젝트 버전 | -| `--target <대상>` | 현재 디렉토리 | 분석 대상 (디렉토리, Docker 이미지, 바이너리 파일, `.zip`/`.tar.gz` 아카이브) | +| `--target <대상>` | 현재 디렉토리 | 분석 대상: 디렉토리(소스 트리, 또는 OS rootfs·빌드 산출물 staging), Docker 이미지, 바이너리 파일, `.zip`/`.tar.gz` 아카이브 | | `--git ` | — | git/GitHub URL을 얕은 클론(shallow) 후 소스로 분석 (비공개 저장소: `GIT_TOKEN` 환경변수) | | `--branch ` | 기본 브랜치 | `--git` 대상의 브랜치, 태그, 커밋 | | `--firmware` | false | `--target` 파일을 펌웨어 모드로 강제 (opt-in 펌웨어 이미지) | | `--analyze ` | — | 공급사 SBOM 검증·분석 (별칭 `--sbom`). CycloneDX/SPDX. `--target`와 배타 | +| `--merge …` | — | CycloneDX SBOM 두 개 이상을 하나로 병합하고 purl 기준으로 중복을 제거한 뒤, 최상위 컴포넌트를 `--project`/`--version`으로 기재. 선택 기능으로, 제출 시스템이 제품당 단일 BOM을 요구할 때 씁니다. 그 외에는 층별로 따로 제출합니다([서버 납품 가이드](../guides/server-delivery.md) 참고). `--target`/`--analyze`/`--git`와 배타 | | `--generate-only` | false | 업로드 없이 로컬에만 저장 | | `--upload-target <대상>` | `dependency-track` | 업로드 대상: `dependency-track`(DT 호환) 또는 `trusca`(네이티브 ingest) | | `--trusca ` | — | TRUSCA에 업로드(= `--upload-target trusca` + project id). `API_URL`과 Bearer `API_KEY` 필요 | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 880b4ce..ec9c958 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -22,11 +22,12 @@ Full options, analysis modes, CI/CD integration, and troubleshooting for BomLens |--------|---------|-------------| | `--project ` | — | **(required)** Project name | | `--version ` | — | **(required)** Project version | -| `--target ` | current directory | What to analyze (directory, Docker image, binary file, or a `.zip`/`.tar.gz` archive) | +| `--target ` | current directory | What to analyze: a directory (source tree, or an OS rootfs / staging build output), a Docker image, a binary file, or a `.zip`/`.tar.gz` archive | | `--git ` | — | Shallow-clone a git/GitHub URL and analyze it as source (private repos: `GIT_TOKEN` env var) | | `--branch ` | default branch | Branch, tag, or commit of the `--git` target | | `--firmware` | false | Force firmware mode on the `--target` file (opt-in firmware image) | | `--analyze ` | — | Validate and analyze a supplier SBOM (alias `--sbom`). CycloneDX/SPDX. Mutually exclusive with `--target` | +| `--merge …` | — | Merge two or more CycloneDX SBOMs into one, dedupe by purl, and stamp the root component with `--project`/`--version`. Optional — for server delivery when a submission system needs a single product BOM; otherwise submit the layers separately (see the [server delivery guide](../guides/server-delivery.md)). Mutually exclusive with `--target`/`--analyze`/`--git` | | `--generate-only` | false | Save locally only, without uploading | | `--upload-target ` | `dependency-track` | Upload destination: `dependency-track` (DT-compatible) or `trusca` (native ingest) | | `--trusca ` | — | Upload to TRUSCA (= `--upload-target trusca` + project id). Needs `API_URL` and a Bearer `API_KEY` | diff --git a/mkdocs.yml b/mkdocs.yml index 6dc1ca3..b8d0b3e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -143,6 +143,7 @@ nav: - No-CLI quickstart: start/no-cli.md - Guides: - By input type: guides/by-input.md + - Server delivery: guides/server-delivery.md - Reports: guides/reports.md - Supplier SBOM: guides/supplier-sbom.md - Firmware: guides/firmware.md