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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
shellcheck docker/lib/validate-sbom.sh docker/lib/convert-to-cdx.sh docker/lib/generate-risk-report.sh || true
echo ""
echo "Checking docker/lib post-process scripts..."
shellcheck docker/lib/normalize-sbom.sh docker/lib/generate-notice.sh docker/lib/stamp-metadata.sh docker/lib/source-detect.sh docker/lib/build-prep.sh || true
shellcheck docker/lib/normalize-sbom.sh docker/lib/generate-notice.sh docker/lib/stamp-metadata.sh docker/lib/source-detect.sh docker/lib/build-prep.sh docker/lib/identify-vendored.sh docker/lib/suggest-vendored.sh docker/lib/reconcile-vendored.sh || true
echo ""
echo "Checking tests/test-scan.sh..."
shellcheck tests/test-scan.sh || true
Expand Down
13 changes: 13 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,23 @@
| trivy-db | 취약점 DB | Apache-2.0 | https://github.com/aquasecurity/trivy-db |
| cosign | SBOM 서명 | Apache-2.0 | https://github.com/sigstore/cosign |
| scancode-toolkit | 정밀 라이선스(opt-in) | Apache-2.0 (데이터셋 일부 CC-BY-4.0 등) | https://github.com/aboutcode-org/scancode-toolkit |
| scanoss (scanoss.py) | vendored 오픈소스 식별(opt-in `SBOM_SCANOSS`) | MIT (동봉 데이터셋 `osadl-copyleft.json`은 CC-BY-4.0) | https://github.com/scanoss/scanoss.py |
| jq | SBOM 가공(헬퍼) | MIT (일부 컴포넌트 BSD/ICU/Lucent) | https://github.com/jqlang/jq |

> 데이터: NVD(취약점 출처)는 public domain이며 "NIST/NVD" 출처 표시가 요구됩니다.

### vendored 오픈소스 식별과 OSSKB API (opt-in)

`--identify-vendored`(빌드: `docker build --build-arg SBOM_SCANOSS=true`)는 클라이언트 `scanoss.py`(MIT)만 번들합니다. SBOM 매칭을 수행하는 SCANOSS Engine(GPL-2.0)은 **포함하지 않으며**, 호스팅 OSSKB API(`api.osskb.org`)를 호출합니다. 그래서 firmware 이미지의 GPL 도구와 달리 base 이미지에 둘 수 있습니다(MIT). 동봉 데이터셋 `osadl-copyleft.json`은 코드가 아닌 CC-BY-4.0 데이터로, 출처 표기만 요구됩니다.

OSSKB API(운영: Software Transparency Foundation) 이용 시 약관 제약:

- 전송되는 것은 소스 코드가 아니라 **파일 지문(해시)**뿐입니다.
- 반환 데이터는 **소프트웨어 식별 목적으로만** 사용할 수 있고, OSSKB 데이터를 **재배포·별도 DB로 캐싱하는 것은 금지**됩니다. `sbom-tools`는 스캔별 SBOM 컴포넌트로만 결과를 내보내므로 이 범위 안입니다.
- 무료·best-effort이며 **요청 빈도 제한(rate limit)**이 있습니다. 대량·전사 운용이나 에어갭 환경에서는 `SCANOSS_API_URL`/`SCANOSS_API_KEY`로 SCANOSS 상용 서비스나 자체 호스팅 엔드포인트를 지정하세요.
- 결과는 "사람 검토가 필요한 식별 힌트"로 제공됩니다(정확도 무보증).
- 약관 원문: https://www.softwaretransparency.org/terms

## 펌웨어 이미지 — `ghcr.io/sktelecom/bomlens-firmware` (GPL 포함, opt-in)

> 무거운 언팩·바이너리 분석 도구와 GPL 컴포넌트를 격리하기 위한 별도 opt-in 이미지입니다.
Expand Down
17 changes: 17 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
ARG COSIGN_VERSION=v2.4.1
ARG SCANCODE_VERSION=32.3.0
ARG SBOM_DEEP_LICENSE=false
# Vendored-OSS identification (opt-in: --build-arg SBOM_SCANOSS=true). scanoss.py
# is MIT, so unlike the firmware GPL tools it can live in the base image; kept
# opt-in only to keep the default image lean and free of outbound API calls.
ARG SBOM_SCANOSS=false
ARG SCANOSS_VERSION=1.25.2
ARG TARGETARCH=amd64
# docker CLI (client only) — lets the web UI source scan launch cdxgen language
# images as sibling containers via the mounted host socket (transitive deps).
Expand All @@ -55,12 +60,12 @@
# jq (helper scripts), curl/tar (installers), file (binary mode),
# git (web UI 'GitHub URL' ingestion: server.py clones into a temp source tree).
# python3 + pip are already in the python:3.12-slim base (web UI + scancode).
RUN apt-get update && apt-get install -y --no-install-recommends \

Check failure on line 63 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Scripts

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

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

Check failure on line 68 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Scripts

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

Expand Down Expand Up @@ -105,6 +110,17 @@
echo "[build] scancode-toolkit skipped (SBOM_DEEP_LICENSE=false)"; \
fi

# scanoss.py (opt-in vendored-OSS identification: --build-arg SBOM_SCANOSS=true).
# 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

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

Check failure on line 133 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Scripts

DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
apt-get update && apt-get install -y --no-install-recommends \
squashfs-tools e2fsprogs p7zip-full unar cpio cabextract \
lzop zstd lz4 liblzo2-2 zlib1g \
Expand All @@ -131,6 +147,7 @@
# 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.scanoss-tools="${SBOM_SCANOSS}" \
com.sktelecom.sbom.gpl-source-offer="https://github.com/sktelecom/sbom-tools/blob/main/THIRD_PARTY_LICENSES.md"

COPY entrypoint.sh /usr/local/bin/run-scan
Expand Down
40 changes: 40 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,46 @@ if command -v jq >/dev/null 2>&1; then
fi
fi

# ========================================================
# Vendored open source (opt-in, SCANOSS) — only meaningful for a source tree.
# Runs for both the CLI source scan (MODE=POSTPROCESS, tree mounted at /src) and
# the web-UI source scan (SOURCE mode, SOURCE_ROOT). When enabled, identify the
# open source copied straight into the sources and merge it into the SBOM before
# stamping/normalizing, so the PURL->CPE fix and the security scan pick it up.
# When disabled, suggest-vendored.sh decides whether to nudge the user (C/C++,
# no package manager, near-empty scan) — off-by-default discovery.
# ========================================================
VENDORED_SRC="${SOURCE_ROOT:-/src}"
if [ "${IDENTIFY_VENDORED:-false}" = "true" ] && [ -d "$VENDORED_SRC" ]; then
echo "[INFO] Identifying vendored open source (SCANOSS)..."
VEND_SBOM="${OUT_PREFIX}_vendored.cdx.json"
if bash "$LIBDIR/identify-vendored.sh" "$VENDORED_SRC" "$VEND_SBOM" "$PROJECT_VERSION"; then
VEND_N=$(jq '[.components[]?] | length' "$VEND_SBOM" 2>/dev/null || echo 0)
# Reconcile against the package-manager scan before merging: drop vendored
# matches whose name a cdxgen/syft component already carries (see
# reconcile-vendored.sh). Prevents duplicate pkg:github components / false
# CVEs when this option is enabled on a normal managed project.
if [ "${VEND_N:-0}" -gt 0 ]; then
DROPPED_N=$(bash "$LIBDIR/reconcile-vendored.sh" "$OUTPUT_FILE" "$VEND_SBOM")
[ "${DROPPED_N:-0}" -gt 0 ] && echo "[INFO] vendored: reconciled ${DROPPED_N} match(es) already covered by the package-manager scan."
VEND_N=$(jq '[.components[]?] | length' "$VEND_SBOM" 2>/dev/null || echo 0)
fi
if [ "${VEND_N:-0}" -gt 0 ]; then
echo "[INFO] vendored components identified: $VEND_N — merging into SBOM."
if bash "$LIBDIR/merge-sbom.sh" "${OUTPUT_FILE}.merged" "$PROJECT_NAME" "$PROJECT_VERSION" "$OUTPUT_FILE" "$VEND_SBOM"; then
mv "${OUTPUT_FILE}.merged" "$OUTPUT_FILE"
else
echo "[WARN] merge of vendored components failed; keeping the original SBOM." >&2
rm -f "${OUTPUT_FILE}.merged"
fi
else
echo "[INFO] no new vendored open source to add (after reconciliation)."
fi
fi
elif [ -d "$VENDORED_SRC" ]; then
bash "$LIBDIR/suggest-vendored.sh" "$OUTPUT_FILE" "$VENDORED_SRC" || true
fi

# ========================================================
# Common pipeline: normalize / deep-license / notice / security / sign
# ========================================================
Expand Down
167 changes: 167 additions & 0 deletions docker/lib/identify-vendored.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/bin/bash
# Copyright 2026 SK Telecom Co., Ltd.
# Licensed under the Apache License, Version 2.0.
#
# identify-vendored.sh — identify open source copied (vendored) into a source tree.
#
# Usage: identify-vendored.sh <source_dir> <output_sbom.json> <version>
# produces <output_sbom.json> (CycloneDX 1.6) whose components are the open-source
# files SCANOSS matched against its public knowledge base (OSSKB).
#
# Why this exists: a C/C++ embedded source tree with no package manager (raw
# CMake/Make) yields an almost-empty SBOM — cdxgen lists each source file as a
# pkg:generic component with no name/version. The real open source lives in files
# copied straight into the tree (liblfds, djbdns, libaes, openssl, …). SCANOSS
# winnowing fingerprints those files and matches them to a known release, so we
# can record them as proper components (name + version + purl).
#
# Precision: only FULL-FILE matches (id == "file") are promoted to components.
# Snippet matches (a few lines copied from elsewhere) are noisy and are skipped
# here, so the SBOM that feeds the security/notice pipeline stays clean.
#
# Privacy: SCANOSS sends file FINGERPRINTS (hashes), not source code, to the
# OSSKB API. Endpoint/credentials are overridable via SCANOSS_API_URL /
# SCANOSS_API_KEY (default: the free api.osskb.org).
#
# Best-effort: a missing tool, no network, or no match degrades to an empty
# components array rather than aborting — the caller always gets a valid SBOM.
set -e

SRC="$1"
OUTPUT="$2"
VERSION="${3:-unknown}"

if [ -z "$SRC" ] || [ ! -d "$SRC" ]; then
echo "[vendored] source directory not found: $SRC" >&2
exit 1
fi
if [ -z "$OUTPUT" ]; then
echo "[vendored] output path is required (usage: identify-vendored.sh <src> <out.json> <version>)" >&2
exit 1
fi

GEN_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Always emit a valid (possibly empty) CycloneDX envelope, used on every
# graceful-degrade path below so the caller never sees a missing/half file.
write_empty() {
jq -n --arg version "$VERSION" --arg ts "$GEN_AT" '
{
bomFormat: "CycloneDX", specVersion: "1.6", version: 1,
metadata: {
timestamp: $ts,
tools: { components: [ { type: "application", name: "scanoss" } ] },
component: { type: "application", name: "vendored", version: $version }
},
components: []
}' > "$OUTPUT"
}

if ! command -v scanoss-py >/dev/null 2>&1; then
echo "[vendored] scanoss-py not installed in this image; skipping vendored identification." >&2
echo "[vendored] Rebuild with: docker build --build-arg SBOM_SCANOSS=true -t bomlens ./docker" >&2
write_empty
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[vendored] ERROR: jq not installed in this image." >&2
exit 1
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
RAW="$WORK/scanoss-raw.json"

# Folders owned by a package manager or a build (their contents are already
# declared by the cdxgen scan, or are generated output). Excluding them keeps
# SCANOSS from re-identifying known dependencies as duplicate pkg:github
# components — the main over-detection risk when this option is enabled on a
# normal, package-managed project. (Name reconciliation in entrypoint.sh is the
# second line of defence for anything that slips through.)
SKIP_FOLDERS="node_modules vendor dist build target out .venv venv \
__pycache__ .gradle .m2 .git bower_components Pods .next .tox .cargo .bundle"
SKIP_ARGS=()
for d in $SKIP_FOLDERS; do SKIP_ARGS+=(--skip-folder "$d"); done
# Ignore tiny files: too little content to identify reliably, a common source of
# spurious file matches (boilerplate headers, empty stubs).
SKIP_ARGS+=(--skip-size 256)

# Run SCANOSS. --skip-snippets keeps it to full-file matching (precision) and is
# faster/lighter on the API. We take the RAW result (default JSON, keyed by file
# path) rather than scanoss' own CycloneDX so we fully control which matches are
# promoted and which provenance properties are attached.
echo "[vendored] SCANOSS: fingerprinting $SRC (file hashes only; source stays local)..."
# shellcheck disable=SC2086
if ! scanoss-py scan "$SRC" --skip-snippets "${SKIP_ARGS[@]}" --output "$RAW" \
${SCANOSS_API_URL:+--apiurl "$SCANOSS_API_URL"} \
${SCANOSS_API_KEY:+--key "$SCANOSS_API_KEY"} >/dev/null 2>&1; then
echo "[vendored] WARN: SCANOSS scan failed (no network / rate limit / bad endpoint); no vendored components." >&2
write_empty
exit 0
fi
if [ ! -s "$RAW" ] || ! jq empty "$RAW" >/dev/null 2>&1; then
echo "[vendored] WARN: SCANOSS produced no usable result; no vendored components." >&2
write_empty
exit 0
fi

# Transform raw SCANOSS JSON -> CycloneDX components.
# - keep only full-file matches (.id == "file")
# - carry SCANOSS' cpe through when present (lets Trivy match CVEs directly;
# normalize-sbom.sh fills the gap for libraries SCANOSS gives no cpe for)
# - normalize the version: OSSKB returns git-tag forms (e.g. "openssl-3.0.0",
# "v1.2.13"), which would otherwise produce a malformed CPE and miss CVEs.
# Strip a leading "<component>-"/"<component>_" and a leading "v" before a digit.
# - tag provenance: bomlens:layer=vendored, identifiedBy=scanoss, match %, source file
# - dedupe by purl (fallback name@version), matching merge-sbom.sh
COMPS=$(jq -c '
[ to_entries[]
| .key as $file
| .value[]?
| select((.id // "") == "file")
| {
type: "library",
name: (.component // ((.purl[0] // "") | sub("^pkg:[^/]+/"; ""))),
version: ( (.component // "") as $c
| (.version // "")
| ltrimstr($c + "-") | ltrimstr($c + "_")
| sub("^[vV](?=[0-9])"; "") ),
purl: (.purl[0] // null),
cpe: (.cpe[0]? // null),
licenses: ( [ .licenses[]?.name // empty ]
| map(select(. != null and . != "")) | unique
| map({ license: { name: . } }) ),
properties: ( [
{ name: "bomlens:layer", value: "vendored" },
{ name: "bomlens:identifiedBy", value: "scanoss" },
{ name: "bomlens:scanoss:match", value: (.matched // "") },
{ name: "bomlens:scanoss:file", value: $file },
{ name: "bomlens:scanoss:purl", value: (.purl[0] // "") }
] | map(select((.value // "") != "")) )
}
| with_entries(select(.value != null and .value != "" and .value != []))
| select((.name // "") != "")
]
| group_by(.purl // ((.name // "") + "@" + (.version // "")))
| map(.[0])
| sort_by(.purl // ((.name // "") + "@" + (.version // "")))
' "$RAW" 2>/dev/null || echo '[]')

NCOMP=$(echo "$COMPS" | jq 'length' 2>/dev/null || echo 0)

jq -n \
--argjson comps "$COMPS" \
--arg version "$VERSION" \
--arg ts "$GEN_AT" '
{
bomFormat: "CycloneDX",
specVersion: "1.6",
version: 1,
metadata: {
timestamp: $ts,
tools: { components: [ { type: "application", name: "scanoss" } ] }
},
components: $comps
}' > "$OUTPUT"

echo "[vendored] SBOM written: $OUTPUT (vendored components=${NCOMP})"
27 changes: 25 additions & 2 deletions docker/lib/normalize-sbom.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ SORT_FILTER='(.components) |= (if type=="array" then sort_by(.purl // ((.name //
# component name/version are retained); valid namespaced swift purls are untouched.
PURL_FIX='(.metadata.component) |= (if (has("purl") and (.purl|test("^pkg:swift/[^/]+@"))) then with_entries(select(.key!="purl")) else . end) | (.components) |= (if type=="array" then map(if (has("purl") and (.purl|test("^pkg:swift/[^/]+@"))) then with_entries(select(.key!="purl")) else . end) else . end)'

# Make vendored (SCANOSS-identified) components reachable by the security scan.
# SCANOSS labels C/C++ matches with pkg:github/<owner>/<repo> PURLs, which Trivy
# does NOT use for CVE matching — it matches OS/language PURLs and CPEs. Without a
# CPE these components are identified but carry no vulnerabilities, breaking the
# identify->CVE chain. For components SCANOSS already gave a cpe we leave it alone;
# otherwise we look the version-stripped PURL coordinate up in vendored-purl-map.json
# and synthesize a cpe:2.3 (NVD). Coordinates not in the map (niche libraries with
# no NVD record) keep their PURL and are simply identified, not vuln-matched.
VMAP_JSON='{}'
[ -f "$SCRIPT_DIR/vendored-purl-map.json" ] && VMAP_JSON=$(cat "$SCRIPT_DIR/vendored-purl-map.json")
VENDORED_CPE_FIX='(.components) |= (if type=="array" then map(
if ( ((.properties // []) | map(select(.name=="bomlens:identifiedBy" and .value=="scanoss")) | length) > 0 )
and (.cpe == null) and (.purl != null) and ((.version // "") != "")
then
( .purl | split("@")[0] | split("?")[0] ) as $coord
| ($vmap[$coord]) as $m
| (if ($m != null)
then . + { cpe: ("cpe:2.3:a:" + $m.cpe_vendor + ":" + $m.cpe_product + ":" + .version + ":*:*:*:*:*:*:*") }
else . end)
else . end
) else . end)'

# Always: normalize component license aliases to SPDX ids. cdxgen records some
# licenses as non-SPDX free text ("Expat license", "Apache License 2.0"); the v1.3
# web UI surfaces (license filter, distribution card, dependency tree) read these
Expand Down Expand Up @@ -75,10 +97,11 @@ if [ "$MODE" = "--stable" ]; then
# cdxgen further leaks the random name of the temp virtualenv it builds to
# resolve python deps (cdxgen-venv-XXXXXX) into component evidence values, so
# the same input yields a different byte stream each run; pin that suffix too.
jq -S "
jq -S --argjson vmap "$VMAP_JSON" "
${NORMALIZE_DEF}
${NULL_FIX}
| ${PURL_FIX}
| ${VENDORED_CPE_FIX}
| ${LICENSE_FIX}
| ${SORT_FILTER}
| walk(if type==\"object\" and has(\"timestamp\") then .timestamp = \"1970-01-01T00:00:00Z\" else . end)
Expand All @@ -91,7 +114,7 @@ if [ "$MODE" = "--stable" ]; then
| del(.serialNumber)
" "$SBOM" > "$TMP"
else
jq -S "${NORMALIZE_DEF} ${NULL_FIX} | ${PURL_FIX} | ${LICENSE_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP"
jq -S --argjson vmap "$VMAP_JSON" "${NORMALIZE_DEF} ${NULL_FIX} | ${PURL_FIX} | ${VENDORED_CPE_FIX} | ${LICENSE_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP"
fi

mv "$TMP" "$SBOM"
Expand Down
45 changes: 45 additions & 0 deletions docker/lib/reconcile-vendored.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/bash
# Copyright 2026 SK Telecom Co., Ltd.
# Licensed under the Apache License, Version 2.0.
#
# reconcile-vendored.sh — drop vendored matches the package-manager scan already covers.
#
# Usage: reconcile-vendored.sh <base_sbom> <vendored_sbom>
# Rewrites <vendored_sbom> in place, removing every component whose name (case-
# insensitive) already appears in <base_sbom>. Prints the number dropped.
#
# Why: when --identify-vendored runs on a normal package-managed project, SCANOSS
# may file-match a declared dependency (e.g. node_modules/lodash). That would land
# as a duplicate pkg:github component with a possibly-wrong CPE — over-detection
# and false CVEs. The authoritative package-manager identity wins; only genuinely
# new finds (real vendored source) survive. The generic merge-sbom.sh dedup cannot
# do this (different PURL ecosystems never match) and must stay unchanged — layered
# server SBOMs legitimately repeat names across layers.
#
# Best-effort: any error leaves the vendored SBOM untouched and reports 0 dropped.
set -e

BASE="$1"
VEND="$2"

if [ -z "$BASE" ] || [ -z "$VEND" ] || [ ! -f "$BASE" ] || [ ! -f "$VEND" ]; then
echo 0; exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
echo 0; exit 0
fi

before=$(jq '[.components[]?] | length' "$VEND" 2>/dev/null || echo 0)
known=$(jq -c '[.components[]?.name // empty | ascii_downcase] | unique' "$BASE" 2>/dev/null || echo '[]')

TMP="$(mktemp)"
if jq --argjson known "$known" \
'.components |= map(select((((.name // "") | ascii_downcase) as $n | ($known | index($n))) | not))' \
"$VEND" > "$TMP" 2>/dev/null; then
mv "$TMP" "$VEND"
else
rm -f "$TMP"
fi

after=$(jq '[.components[]?] | length' "$VEND" 2>/dev/null || echo "$before")
echo $((before - after))
Loading
Loading