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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# ========================================================
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
106 changes: 106 additions & 0 deletions docker/lib/merge-sbom.sh
Original file line number Diff line number Diff line change
@@ -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 <output.json> <project_name> <project_version> <in1> <in2> [...]
# produces <output.json> (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-<index>), 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 <output.json> <name> <version> <in1> <in2> [...]" >&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 <big-json>` 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))"
7 changes: 4 additions & 3 deletions docker/lib/stamp-metadata.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docker/web/frontend/src/components/InputTypeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface Props {

const LABEL_KEY: Record<SourceType, string> = {
"current-dir": "source.currentDir",
"rootfs-dir": "source.rootfsDir",
"git-url": "source.gitUrl",
"zip-upload": "source.zipUpload",
"sbom-upload": "source.sbomUpload",
Expand Down
40 changes: 27 additions & 13 deletions docker/web/frontend/src/components/ScanForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ const ACCEPT: Record<UploadKind, string> = {
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<SourceType, { label: string; placeholder: string; hint: string }>
> = {
"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("");
Expand All @@ -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;

Expand Down Expand Up @@ -193,25 +215,17 @@ export function ScanForm({ running, capabilities, onRun }: Props) {
</div>
)}

{isText && (
{textInput && (
<div className="space-y-2">
<Label htmlFor="target">
{source === "git-url" ? t("source.gitUrl") : t("source.dockerImage")}
</Label>
<Label htmlFor="target">{t(textInput.label)}</Label>
<Input
id="target"
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={
source === "git-url"
? t("source.gitPlaceholder")
: t("source.dockerPlaceholder")
}
placeholder={t(textInput.placeholder)}
disabled={busy}
/>
<p className="text-xs text-muted-foreground">
{source === "git-url" ? t("source.gitHint") : t("source.dockerHint")}
</p>
<p className="text-xs text-muted-foreground">{t(textInput.hint)}</p>
</div>
)}

Expand Down
2 changes: 2 additions & 0 deletions docker/web/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -89,6 +90,7 @@ export type SourceType =

export const SOURCE_TYPES: SourceType[] = [
"current-dir",
"rootfs-dir",
"git-url",
"zip-upload",
"sbom-upload",
Expand Down
3 changes: 3 additions & 0 deletions docker/web/frontend/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions docker/web/frontend/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions docker/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>, a subfolder of /src)
# git-url -> clone <target> then MODE=SOURCE
# zip-upload -> extract uploaded zip then MODE=SOURCE
# sbom-upload -> MODE=ANALYZE on the uploaded SBOM
Expand Down Expand Up @@ -96,6 +97,30 @@
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 <host-path>` 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):

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
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
Expand Down Expand Up @@ -629,6 +654,18 @@
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
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/by-input.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <build-dir> --all --generate-only`(syft)로 분석합니다.
- 순수 CMake/Make 소스는 매니저 메타데이터가 없어 SBOM이 희소할 수 있습니다. 이때는 `--deep-license`로 1st-party 소스의 라이선스 헤더를 보강하고, 빌드 산출물(설치된 라이브러리가 있는 staging/rootfs)은 별도로 `$SBOM --target <build-dir> --all --generate-only`(syft)로 분석합니다. OS rootfs와 애플리케이션, 정적 링크 의존성을 층별로 나눠 만드는 서버 납품 전체 흐름은 [서버 납품 가이드](server-delivery.md)를 참고하세요.
- 패키지 매니저가 없어도 위험분석보고서는 생성되며, 탐지된 구성요소의 라이선스와 취약점을 집계합니다.

**산출물**: 고지문, SBOM, 위험분석보고서 (3종)
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/by-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <build-dir> --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 <build-dir> --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)
Expand Down
Loading
Loading