From 1473a4f15b2acdd4ec6aae67b08ecc31f6bb28c7 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Thu, 16 Jul 2026 18:23:46 +0900 Subject: [PATCH] feat(scanner): propagate copyleft-strength license classes to artifacts The copyleft-strength classification (network/strong/weak copyleft, permissive, uncategorized) existed only in the web UI, so the SBOM a supplier submits and the risk report a reviewer reads carried less information than the screen showed. Mirror the UI's tier logic in the shared license-flags.jq and stamp every component with a bomlens:licenseClass property in normalize (worst-of precedence identical to the UI, including unknown licenses outranking confirmed-permissive; no-license components stay uncategorized, never assumed permissive). The risk report gains a per-class count table and lists the network/strong-copyleft components driving the exposure (md + html). The report recomputes with the same jq rather than trusting the property, so ANALYZE inputs that predate the property classify identically. A drift guard in test-postprocess.sh diffs the license-id sets and tier patterns between licenses.ts and the jq mirror, so the two implementations cannot silently diverge. Classification cases cover each tier, dual-license worst-of, and the no-license fallback; the normalize snapshots were deliberately regenerated (the only diff is the new property). Output stays deterministic under --byte-stable. Docs: reports-explained (en/ko) notes the property and the new report section. --- docker/lib/generate-risk-report.sh | 83 +++++++++++- docker/lib/license-flags.jq | 47 +++++++ docker/lib/normalize-sbom.sh | 19 ++- docs/concepts/reports-explained.ko.md | 2 + docs/concepts/reports-explained.md | 2 + .../snapshots/normalize-license-aliases.json | 42 ++++++ .../snapshots/vendored-merged-normalized.json | 24 ++++ tests/test-postprocess.sh | 128 ++++++++++++++++++ 8 files changed, 345 insertions(+), 2 deletions(-) diff --git a/docker/lib/generate-risk-report.sh b/docker/lib/generate-risk-report.sh index 7ffa59fa..7f8e5293 100644 --- a/docker/lib/generate-risk-report.sh +++ b/docker/lib/generate-risk-report.sh @@ -102,6 +102,46 @@ if [ -f "$NOTICE" ]; then LIC_COUNT=$(grep -c '^License: ' "$NOTICE" 2>/dev/null || echo 0) fi +# -------------------------------------------------------- +# License classification (copyleft strength) from the finished SBOM. Uses the +# SAME classifier as normalize-sbom.sh and the web UI (the shared +# license-flags.jq, which mirrors licenses.ts), so the report's counts always +# agree with the bomlens:licenseClass properties and the UI badges — even for +# an SBOM that predates the property. Skipped when no BOM artifact exists. +# -------------------------------------------------------- +BOM="${OUT_PREFIX}_bom.json" +LIB_DIR="$(cd "$(dirname "$0")" && pwd)" +LIC_CLASS='null' +if [ -f "$BOM" ] && [ -f "$LIB_DIR/license-flags.jq" ] && jq empty "$BOM" >/dev/null 2>&1; then + LIC_CLASS=$(jq -c "$(cat "$LIB_DIR/license-flags.jq")"' + [ .components[]? | { class: component_license_class, + label: ((.name // "?") + "@" + (.version // "?")) } ] as $rows + | { nc: ([$rows[] | select(.class=="network-copyleft") | .label]), + sc: ([$rows[] | select(.class=="strong-copyleft") | .label]), + wk: ([$rows[] | select(.class=="weak-copyleft")] | length), + pm: ([$rows[] | select(.class=="permissive")] | length), + un: ([$rows[] | select(.class=="uncategorized")] | length) } + ' "$BOM" 2>/dev/null || echo 'null') +fi +HAS_LIC_CLASS=false +NC=0; SC=0; WK=0; PM=0; UN=0; COPYLEFT_TOTAL=0 +COPYLEFT_TOP='[]' +if [ "$LIC_CLASS" != "null" ]; then + HAS_LIC_CLASS=true + NC=$(echo "$LIC_CLASS" | jq '.nc | length') + SC=$(echo "$LIC_CLASS" | jq '.sc | length') + WK=$(echo "$LIC_CLASS" | jq '.wk') + PM=$(echo "$LIC_CLASS" | jq '.pm') + UN=$(echo "$LIC_CLASS" | jq '.un') + COPYLEFT_TOTAL=$((NC + SC)) + # Up to 10 drivers of the copyleft exposure: network-copyleft first, then + # strong; each keeps the SBOM's (sorted) component order, so the list is + # deterministic. + COPYLEFT_TOP=$(echo "$LIC_CLASS" | jq -c ' + ([.nc[] | {label: ., class: "network-copyleft"}] + + [.sc[] | {label: ., class: "strong-copyleft"}]) | .[0:10]') +fi + # -------------------------------------------------------- # Markdown # -------------------------------------------------------- @@ -151,6 +191,25 @@ fi else echo "- 식별된 distinct 라이선스: ${LIC_COUNT}건 (상세는 \`${OUT_PREFIX}_NOTICE.{txt,html}\` 참조)" fi + if [ "$HAS_LIC_CLASS" = "true" ]; then + echo "" + echo "### 라이선스 분류 (카피레프트 강도)" + echo "" + echo "각 컴포넌트는 SBOM에 \`bomlens:licenseClass\` 속성으로도 기록되어 있습니다. 인식되지 않은 라이선스는 permissive로 간주하지 않고 미분류(uncategorized)로 남깁니다." + echo "" + echo "| Network copyleft | Strong copyleft | Weak copyleft | Permissive | 미분류 |" + echo "|---:|---:|---:|---:|---:|" + echo "| ${NC} | ${SC} | ${WK} | ${PM} | ${UN} |" + if [ "$COPYLEFT_TOTAL" -gt 0 ]; then + echo "" + echo "카피레프트 노출을 만드는 컴포넌트 (network/strong, 최대 10개):" + echo "" + echo "$COPYLEFT_TOP" | jq -r '.[] | "- `" + .label + "` (" + .class + ")"' + if [ "$COPYLEFT_TOTAL" -gt 10 ]; then + echo "- 외 $((COPYLEFT_TOTAL - 10))개 (전체는 SBOM의 \`bomlens:licenseClass\` 속성 참조)" + fi + fi + fi echo "" echo "## ${S_NEXT}. 다음 단계" echo "" @@ -294,6 +353,28 @@ HTMLSEC else echo "

식별된 distinct 라이선스: ${LIC_COUNT}건 (상세는 NOTICE 산출물 참조).

" fi + if [ "$HAS_LIC_CLASS" = "true" ]; then + echo "

라이선스 분류 (카피레프트 강도)

" + echo "

각 컴포넌트는 SBOM에 bomlens:licenseClass 속성으로도 기록되어 있습니다. 인식되지 않은 라이선스는 permissive로 간주하지 않고 미분류(uncategorized)로 남깁니다.

" + cat < + Network copyleft ${NC} + Strong copyleft ${SC} + Weak copyleft ${WK} + Permissive ${PM} + 미분류 ${UN} + +HTMLLIC + if [ "$COPYLEFT_TOTAL" -gt 0 ]; then + echo "

카피레프트 노출을 만드는 컴포넌트 (network/strong, 최대 10개):

" + echo "" + fi + fi echo "

${S_NEXT}. 다음 단계

" echo "
    " @@ -307,4 +388,4 @@ HTMLSEC echo "" } > "$HTML" -echo "[risk] generated: $MD, $HTML (conformance=${CONF_RESULT}, vulns total=${TOTAL}, crit=${C}, high=${H})" +echo "[risk] generated: $MD, $HTML (conformance=${CONF_RESULT}, vulns total=${TOTAL}, crit=${C}, high=${H}, copyleft=${COPYLEFT_TOTAL})" diff --git a/docker/lib/license-flags.jq b/docker/lib/license-flags.jq index 2d1dd154..d0d9d435 100644 --- a/docker/lib/license-flags.jq +++ b/docker/lib/license-flags.jq @@ -17,3 +17,50 @@ def license_flag($s): if ($n | test("openrail|\\brail\\b|responsible ai|community license|\\bllama|\\bgemma\\b|falcon llm")) then "behavioral-use" elif ($n | test("cc by nc|non ?commercial")) then "non-commercial" else "" end; + +# --------------------------------------------------------------------------- +# license_class — copyleft-strength classification (bomlens:licenseClass). +# +# MIRROR of the web UI classifier in +# docker/web/frontend/src/lib/licenses.ts (licenseRiskTier + TIER_RANK): the +# same permissive allowlist, the same tier patterns in the same order, and the +# same worst-of precedence, so the SBOM property, the risk report and the UI +# badge never disagree. tests/test-postprocess.sh diffs the id sets and +# patterns of the two files, so a change on either side without the matching +# change on the other fails CI. +# +# The headline rule (same as the UI): an unrecognised license is NEVER assumed +# permissive — it falls to "uncategorized" (a human must look). Orthogonal to +# license_flag above: a component can carry both bomlens:licenseClass and +# bomlens:licenseReview. +# --------------------------------------------------------------------------- + +# Known permissive SPDX ids (uppercased). An allowlist, not a heuristic — keep +# in sync with the PERMISSIVE set in licenses.ts (single line: the drift guard +# extracts the quoted ids from this def). +def permissive_ids: ["MIT","MIT-0","ISC","0BSD","BSD-2-CLAUSE","BSD-3-CLAUSE","APACHE-2.0","APACHE-1.1","ZLIB","UNLICENSE","BSL-1.0","PSF-2.0","PYTHON-2.0","CC0-1.0","WTFPL","NCSA","X11"]; + +# Classify ONE license id/name/expression. Order matters: AGPL and LGPL are +# matched before the bare GPL test so they don't fall to strong-copyleft. +def license_class($s): + (($s // "") | sub("^\\s+"; "") | sub("\\s+$"; "")) as $id + | if $id == "" then "uncategorized" + elif ((permissive_ids | index($id | ascii_upcase)) != null) then "permissive" + elif ($id | test("\\bAGPL"; "i")) then "network-copyleft" + elif ($id | test("\\bLGPL"; "i")) then "weak-copyleft" + elif ($id | test("\\b(MPL|EPL|CDDL|CPL|OSL|EUPL|CeCILL|Sleepycat)\\b"; "i")) then "weak-copyleft" + elif ($id | test("\\bGPL"; "i")) then "strong-copyleft" + else "uncategorized" end; + +# Worst-of ranking across a component's licenses (licenses.ts TIER_RANK): +# network > strong > weak > uncategorized > permissive. Known copyleft outranks +# an unknown license; an unknown license outranks known-permissive. +def class_rank: {"network-copyleft": 5, "strong-copyleft": 4, "weak-copyleft": 3, "uncategorized": 2, "permissive": 1}; + +# One class for a whole CycloneDX component: the strongest class across its +# non-empty license ids/names/expressions (the same strings the web server +# extracts for the UI); a component with no license info is "uncategorized". +def component_license_class: + [ (.licenses // [])[] | (.license.id // .license.name // .expression // "") | select(. != "") ] + | if length == 0 then "uncategorized" + else map(license_class(.)) | max_by(class_rank[.]) end; diff --git a/docker/lib/normalize-sbom.sh b/docker/lib/normalize-sbom.sh index 429424bb..453003ae 100755 --- a/docker/lib/normalize-sbom.sh +++ b/docker/lib/normalize-sbom.sh @@ -213,6 +213,22 @@ LICENSE_REVIEW_FIX='(.components) |= (if type=="array" then map( else . end ) else . end)' +# Stamp every component with its copyleft-strength class as a +# bomlens:licenseClass property (network-copyleft / strong-copyleft / +# weak-copyleft / permissive / uncategorized), using the SAME classifier the +# web UI computes from (license-flags.jq mirrors licenses.ts), so the SBOM a +# supplier submits, the risk report and the UI badge never disagree. When a +# component carries several licenses the strongest class wins (licenses.ts +# TIER_RANK precedence); no license info means "uncategorized", never +# permissive. Runs after LICENSE_FIX so normalized .license.id is in place. +# Any previous bomlens:licenseClass is replaced and the property is appended +# at a fixed position, so re-runs and --byte-stable output stay byte-identical. +# Orthogonal to bomlens:licenseReview: a component can carry both. +LICENSE_CLASS_FIX='(.components) |= (if type=="array" then map( + .properties = (((.properties // []) | map(select(.name != "bomlens:licenseClass"))) + + [{name:"bomlens:licenseClass", value: component_license_class}]) +) else . end)' + if [ "$MODE" = "--stable" ]; then # Reproducible build: pin every timestamp (metadata + annotations + tools), # drop random serial number. cdxgen also embeds a human-readable build date @@ -231,6 +247,7 @@ if [ "$MODE" = "--stable" ]; then | ${OS_SRC_FIX} | ${LICENSE_FIX} | ${LICENSE_REVIEW_FIX} + | ${LICENSE_CLASS_FIX} | ${SORT_FILTER} | walk(if type==\"object\" and has(\"timestamp\") then .timestamp = \"1970-01-01T00:00:00Z\" else . end) | walk(if type==\"string\" then gsub(\"cdxgen-venv-[A-Za-z0-9]+\"; \"cdxgen-venv\") else . end) @@ -242,7 +259,7 @@ if [ "$MODE" = "--stable" ]; then | del(.serialNumber) " "$SBOM" > "$TMP" else - jq -S --argjson vmap "$VMAP_JSON" "${LICENSE_FLAGS_DEF} ${NORMALIZE_DEF} ${NULL_FIX} | ${DROP_EMPTY_FILES} | ${PYRANGE_DEDUP} | ${PURL_FIX} | ${VENDORED_CPE_FIX} | ${OS_SRC_FIX} | ${LICENSE_FIX} | ${LICENSE_REVIEW_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP" + jq -S --argjson vmap "$VMAP_JSON" "${LICENSE_FLAGS_DEF} ${NORMALIZE_DEF} ${NULL_FIX} | ${DROP_EMPTY_FILES} | ${PYRANGE_DEDUP} | ${PURL_FIX} | ${VENDORED_CPE_FIX} | ${OS_SRC_FIX} | ${LICENSE_FIX} | ${LICENSE_REVIEW_FIX} | ${LICENSE_CLASS_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP" fi mv "$TMP" "$SBOM" diff --git a/docs/concepts/reports-explained.ko.md b/docs/concepts/reports-explained.ko.md index 626b315a..e7e9ce7b 100644 --- a/docs/concepts/reports-explained.ko.md +++ b/docs/concepts/reports-explained.ko.md @@ -64,6 +64,8 @@ BomLens는 각 컴포넌트의 릴리스 주기가 상위(upstream) 지원 종 오픈소스위험분석보고서는 취약점을 심각도별로 집계하고 권고 대응 기한(Critical 7일, High 30일)을 명시합니다. 라이선스 요약도 담고 있으며, 공급사 SBOM을 분석한 경우에는 포맷 적합성 결과가 더해집니다. +라이선스 요약에는 카피레프트 강도에 따른 컴포넌트 분류도 함께 담기며, 웹 UI가 보여 주는 분류와 같은 기준을 사용합니다. SBOM의 각 컴포넌트에는 `bomlens:licenseClass` 속성이 `network-copyleft`, `strong-copyleft`, `weak-copyleft`, `permissive`, `uncategorized` 중 하나로 기록되고, 보고서에는 분류별 개수와 카피레프트 노출을 만드는 컴포넌트 목록이 더해집니다. 인식되지 않은 라이선스는 permissive로 간주하지 않고 `uncategorized`로 남겨 사람이 확인하도록 합니다. + ## 관련 문서 - [보고서 생성](../guides/reports.ko.md) diff --git a/docs/concepts/reports-explained.md b/docs/concepts/reports-explained.md index 24f2d260..3e3f8ada 100644 --- a/docs/concepts/reports-explained.md +++ b/docs/concepts/reports-explained.md @@ -64,6 +64,8 @@ Sitting on a supported release cycle is not the same as running its latest versi The open-source risk report aggregates vulnerabilities by severity with recommended response deadlines (Critical 7 days, High 30 days). It includes a license summary, and for a supplier SBOM it adds the format conformance result. +The license summary also classifies components by copyleft strength, with the same rules the web UI uses. Each component in the SBOM carries a `bomlens:licenseClass` property holding one of `network-copyleft`, `strong-copyleft`, `weak-copyleft`, `permissive` or `uncategorized`, and the report shows a per-class count plus the components that drive the copyleft exposure. A license the tool does not recognize is never assumed permissive; it stays `uncategorized` for a human to review. + ## Related - [Generating reports](../guides/reports.md) diff --git a/tests/snapshots/normalize-license-aliases.json b/tests/snapshots/normalize-license-aliases.json index 30d5a19c..5f507fcf 100644 --- a/tests/snapshots/normalize-license-aliases.json +++ b/tests/snapshots/normalize-license-aliases.json @@ -10,6 +10,12 @@ } ], "name": "coverage", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "permissive" + } + ], "type": "library", "version": "7.4.0" }, @@ -23,6 +29,12 @@ } ], "name": "flask", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "permissive" + } + ], "type": "library", "version": "3.0.0" }, @@ -35,6 +47,12 @@ } ], "name": "mccabe", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "permissive" + } + ], "type": "library", "version": "0.7.0" }, @@ -45,6 +63,12 @@ } ], "name": "packaging", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "uncategorized" + } + ], "type": "library", "version": "23.0" }, @@ -55,6 +79,12 @@ } ], "name": "python-dateutil", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "uncategorized" + } + ], "type": "library", "version": "2.9.0" }, @@ -67,6 +97,12 @@ } ], "name": "requests", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "permissive" + } + ], "type": "library", "version": "2.31.0" }, @@ -79,6 +115,12 @@ } ], "name": "six", + "properties": [ + { + "name": "bomlens:licenseClass", + "value": "permissive" + } + ], "type": "library", "version": "1.16.0" } diff --git a/tests/snapshots/vendored-merged-normalized.json b/tests/snapshots/vendored-merged-normalized.json index e94e9add..a0818031 100644 --- a/tests/snapshots/vendored-merged-normalized.json +++ b/tests/snapshots/vendored-merged-normalized.json @@ -7,6 +7,10 @@ { "name": "bomlens:layer", "value": "trelay" + }, + { + "name": "bomlens:licenseClass", + "value": "uncategorized" } ], "purl": "pkg:generic/config.c", @@ -18,6 +22,10 @@ { "name": "bomlens:layer", "value": "trelay" + }, + { + "name": "bomlens:licenseClass", + "value": "uncategorized" } ], "purl": "pkg:generic/lfds.c", @@ -29,6 +37,10 @@ { "name": "bomlens:layer", "value": "trelay" + }, + { + "name": "bomlens:licenseClass", + "value": "uncategorized" } ], "purl": "pkg:generic/main.c", @@ -40,6 +52,10 @@ { "name": "bomlens:layer", "value": "trelay" + }, + { + "name": "bomlens:licenseClass", + "value": "uncategorized" } ], "purl": "pkg:generic/ssl_lib.c", @@ -78,6 +94,10 @@ { "name": "bomlens:layer", "value": "layer-1" + }, + { + "name": "bomlens:licenseClass", + "value": "permissive" } ], "purl": "pkg:github/liblfds/liblfds", @@ -118,6 +138,10 @@ { "name": "bomlens:layer", "value": "layer-1" + }, + { + "name": "bomlens:licenseClass", + "value": "permissive" } ], "purl": "pkg:github/openssl/openssl", diff --git a/tests/test-postprocess.sh b/tests/test-postprocess.sh index 11a9197e..2084bb69 100755 --- a/tests/test-postprocess.sh +++ b/tests/test-postprocess.sh @@ -237,6 +237,134 @@ date_expr=$(jq -r '.components[] | select(.name=="python-dateutil") | .licenses[ pkg_expr=$(jq -r '.components[] | select(.name=="packaging") | .licenses[0].expression // "ABSENT"' "$WORK/c.json") [ "$pkg_expr" = "Apache-2.0 OR BSD-2-Clause" ] && pass "compound expression left untouched" || fail "packaging expression='$pkg_expr'" +echo "== license-class: bomlens:licenseClass copyleft-strength classification ==" +# normalize-sbom.sh stamps every component with exactly one copyleft-strength +# class, using the license-flags.jq classifier that MIRRORS the web UI's +# licenses.ts, so the submitted SBOM carries the same classification the UI +# shows. Headline rule: an unrecognised license is never assumed permissive. +cat > "$WORK/lc.json" <<'JSON' +{"bomFormat":"CycloneDX","specVersion":"1.6","components":[ + {"type":"library","name":"agpl-lib","version":"1.0","licenses":[{"license":{"id":"AGPL-3.0-only"}}]}, + {"type":"library","name":"gpl-lib","version":"1.0","licenses":[{"license":{"id":"GPL-3.0-only"}}]}, + {"type":"library","name":"lgpl-lib","version":"1.0","licenses":[{"license":{"id":"LGPL-2.1-only"}}]}, + {"type":"library","name":"mpl-lib","version":"1.0","licenses":[{"license":{"id":"MPL-2.0"}}]}, + {"type":"library","name":"mit-lib","version":"1.0","licenses":[{"license":{"id":"MIT"}}]}, + {"type":"library","name":"mystery-lib","version":"1.0","licenses":[{"license":{"name":"Custom Corp License"}}]}, + {"type":"library","name":"bare-lib","version":"1.0"}, + {"type":"library","name":"dual-lib","version":"1.0","licenses":[{"expression":"GPL-2.0-only OR MIT"}]}, + {"type":"library","name":"mixed-lib","version":"1.0","licenses":[{"license":{"id":"MIT"}},{"license":{"name":"Custom Corp License"}}]}, + {"type":"machine-learning-model","name":"llama-model","version":"3","licenses":[{"license":{"name":"Llama 3 Community License"}}]} +]} +JSON +bash "$LIB/normalize-sbom.sh" "$WORK/lc.json" >/dev/null 2>&1 +lclass() { jq -r --arg n "$1" '.components[] | select(.name==$n) + | [(.properties // [])[] | select(.name=="bomlens:licenseClass") | .value] | first // "ABSENT"' "$WORK/lc.json"; } +[ "$(lclass agpl-lib)" = "network-copyleft" ] && pass "AGPL -> network-copyleft" || fail "agpl-lib class='$(lclass agpl-lib)', expected network-copyleft" +[ "$(lclass gpl-lib)" = "strong-copyleft" ] && pass "GPL -> strong-copyleft" || fail "gpl-lib class='$(lclass gpl-lib)', expected strong-copyleft" +[ "$(lclass lgpl-lib)" = "weak-copyleft" ] && pass "LGPL -> weak-copyleft (matched before the bare GPL test)" || fail "lgpl-lib class='$(lclass lgpl-lib)', expected weak-copyleft" +[ "$(lclass mpl-lib)" = "weak-copyleft" ] && pass "MPL -> weak-copyleft" || fail "mpl-lib class='$(lclass mpl-lib)', expected weak-copyleft" +[ "$(lclass mit-lib)" = "permissive" ] && pass "MIT -> permissive (allowlist match)" || fail "mit-lib class='$(lclass mit-lib)', expected permissive" +[ "$(lclass mystery-lib)" = "uncategorized" ] && pass "unknown license -> uncategorized, never assumed permissive" || fail "mystery-lib class='$(lclass mystery-lib)', expected uncategorized" +[ "$(lclass bare-lib)" = "uncategorized" ] && pass "no license info -> uncategorized" || fail "bare-lib class='$(lclass bare-lib)', expected uncategorized" +[ "$(lclass dual-lib)" = "strong-copyleft" ] && pass "dual license (GPL-2.0-only OR MIT) -> strongest wins" || fail "dual-lib class='$(lclass dual-lib)', expected strong-copyleft" +[ "$(lclass mixed-lib)" = "uncategorized" ] && pass "MIT + unknown -> uncategorized (unknown outranks confirmed-permissive)" || fail "mixed-lib class='$(lclass mixed-lib)', expected uncategorized" +# A licenseReview-flagged component still gets a class: the two properties coexist. +lr=$(jq -r '.components[] | select(.name=="llama-model") + | [(.properties // [])[] | select(.name=="bomlens:licenseReview") | .value] | first // "ABSENT"' "$WORK/lc.json") +[ "$lr" = "behavioral-use" ] && [ "$(lclass llama-model)" = "uncategorized" ] \ + && pass "bomlens:licenseReview and bomlens:licenseClass coexist on one component" \ + || fail "llama-model review='$lr' class='$(lclass llama-model)', expected behavioral-use + uncategorized" +# Every component carries exactly ONE class property (idempotent re-run included). +bash "$LIB/normalize-sbom.sh" "$WORK/lc.json" >/dev/null 2>&1 +lc_bad=$(jq '[.components[] | [(.properties // [])[] | select(.name=="bomlens:licenseClass")] | length | select(. != 1)] | length' "$WORK/lc.json") +[ "$lc_bad" = "0" ] && pass "every component has exactly one licenseClass after a re-run (idempotent)" || fail "$lc_bad component(s) with != 1 licenseClass property" +# --byte-stable determinism: two --stable runs over the same input are identical. +cat > "$WORK/lcs1.json" <<'JSON' +{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"type":"library","name":"agpl-lib","version":"1.0","licenses":[{"license":{"id":"AGPL-3.0-only"}}]}]} +JSON +cp "$WORK/lcs1.json" "$WORK/lcs2.json" +bash "$LIB/normalize-sbom.sh" "$WORK/lcs1.json" --stable >/dev/null 2>&1 +bash "$LIB/normalize-sbom.sh" "$WORK/lcs2.json" --stable >/dev/null 2>&1 +diff -q "$WORK/lcs1.json" "$WORK/lcs2.json" >/dev/null 2>&1 \ + && pass "--stable output with licenseClass is byte-identical across runs" \ + || fail "licenseClass stamping broke --byte-stable determinism" + +echo "== license-class drift guard: license-flags.jq and licenses.ts share one classifier ==" +# The jq classifier is a hand-written mirror of the frontend's licenses.ts. This +# gate extracts both sides' permissive id sets, tier regex patterns (in match +# order) and tier results, and fails naming the divergence — so neither file can +# gain or lose a license id without the same change on the other side. +LTS="$ROOT_DIR/docker/web/frontend/src/lib/licenses.ts" +LFJ="$LIB/license-flags.jq" +ts_perm=$(sed -n '/const PERMISSIVE = new Set(\[/,/\]);/p' "$LTS" | grep -oE '"[A-Za-z0-9.+-]+"' | tr -d '"' | sort) +jq_perm=$(grep '^def permissive_ids:' "$LFJ" | grep -oE '"[A-Za-z0-9.+-]+"' | tr -d '"' | tr ',' '\n' | sort) +if [ -z "$ts_perm" ] || [ -z "$jq_perm" ]; then + fail "could not extract the permissive id sets (licenses.ts / license-flags.jq changed shape?)" +elif [ "$ts_perm" = "$jq_perm" ]; then + pass "permissive allowlists are identical ($(printf '%s\n' "$ts_perm" | wc -l | tr -d ' ') ids)" +else + fail "permissive allowlists diverged between licenses.ts and license-flags.jq" \ + "$(diff <(printf '%s\n' "$ts_perm") <(printf '%s\n' "$jq_perm") | grep '^[<>]' | sed 's/^/only in license-flags.jq:/')" +fi +# Tier regex patterns, in match order (order decides AGPL/LGPL vs bare GPL). +ts_pat=$(sed -n '/^export function licenseRiskTier/,/^}/p' "$LTS" | grep -oE '/\\b[^/]+/i' | sed 's:^/::; s:/i$::') +jq_pat=$(sed -n '/^def license_class/,/^def class_rank/p' "$LFJ" | grep -oE 'test\("[^"]+"' | sed 's/^test("//; s/"$//; s/\\\\/\\/g') +if [ -z "$ts_pat" ] || [ -z "$jq_pat" ]; then + fail "could not extract the tier patterns (licenses.ts / license-flags.jq changed shape?)" +elif [ "$ts_pat" = "$jq_pat" ]; then + pass "tier patterns match in content and order" +else + fail "tier patterns diverged between licenses.ts and license-flags.jq" \ + "$(diff <(printf '%s\n' "$ts_pat") <(printf '%s\n' "$jq_pat") | grep '^[<>]' | sed 's/^/license-flags.jq:/')" +fi +# Tier results per pattern, in the same order. +ts_tier=$(sed -n '/^export function licenseRiskTier/,/^}/p' "$LTS" | grep -oE 'return "[a-z-]+-copyleft"' | sed 's/return //; s/"//g') +jq_tier=$(sed -n '/^def license_class/,/^def class_rank/p' "$LFJ" | grep -oE 'then "[a-z-]+-copyleft"' | sed 's/then //; s/"//g') +if [ "$ts_tier" = "$jq_tier" ] && [ -n "$ts_tier" ]; then + pass "tier results per pattern match" +else + fail "tier results diverged" "licenses.ts: $(echo "$ts_tier" | tr '\n' ' ') / license-flags.jq: $(echo "$jq_tier" | tr '\n' ' ')" +fi + +echo "== risk-report: license classification summary drives from the SBOM ==" +# generate-risk-report.sh must add the per-class table and the copyleft driver +# list (network/strong, up to 10) when the BOM artifact exists, and skip the +# block gracefully when it does not. +mkdir -p "$WORK/risk" +cp "$WORK/lc.json" "$WORK/risk/proj_1.0_bom.json" +printf 'License: MIT\nLicense: AGPL-3.0-only\n' > "$WORK/risk/proj_1.0_NOTICE.txt" +( cd "$WORK/risk" && bash "$LIB/generate-risk-report.sh" proj_1.0 proj >/dev/null 2>&1 ) +RMD="$WORK/risk/proj_1.0_risk-report.md"; RHTML="$WORK/risk/proj_1.0_risk-report.html" +if [ -f "$RMD" ] && [ -f "$RHTML" ]; then + grep -q '^| 1 | 2 | 2 | 1 | 4 |$' "$RMD" \ + && pass "md counts row matches the fixture (1 network, 2 strong, 2 weak, 1 permissive, 4 uncategorized)" \ + || fail "md classification counts wrong" "$(grep -A2 'Network copyleft' "$RMD")" + grep -q '`agpl-lib@1.0` (network-copyleft)' "$RMD" \ + && pass "md lists the network-copyleft driver by name@version" \ + || fail "md copyleft driver list missing agpl-lib@1.0" + grep -q 'dual-lib@1.0' "$RMD" && grep -q 'gpl-lib@1.0' "$RMD" \ + && pass "md lists the strong-copyleft drivers" \ + || fail "md copyleft driver list missing a strong-copyleft component" + grep -q 'Network copyleft 1' "$RHTML" \ + && pass "html classification pills carry the same counts" \ + || fail "html classification pills missing/wrong" + grep -q '
  1. agpl-lib@1.0 (network-copyleft)
  2. ' "$RHTML" \ + && pass "html lists the copyleft drivers" \ + || fail "html copyleft driver list missing" +else + fail "generate-risk-report.sh produced no md/html with a BOM present" +fi +# Without a BOM artifact the classification block is skipped, not an error. +mkdir -p "$WORK/risk2" +printf 'License: MIT\n' > "$WORK/risk2/proj_1.0_NOTICE.txt" +( cd "$WORK/risk2" && bash "$LIB/generate-risk-report.sh" proj_1.0 proj >/dev/null 2>&1 ) \ + || fail "generate-risk-report.sh failed without a BOM artifact" +if [ -f "$WORK/risk2/proj_1.0_risk-report.md" ] && ! grep -q '라이선스 분류' "$WORK/risk2/proj_1.0_risk-report.md"; then + pass "no BOM artifact -> classification block skipped gracefully" +else + fail "classification block present (or report missing) without a BOM" +fi + echo "== vendored: identify-vendored.sh promotes file matches, drops snippets ==" # Mock scanoss-py (no network/image needed): write the raw SCANOSS fixture to the # tool's --output path so identify-vendored.sh's jq transform is exercised.