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
83 changes: 82 additions & 1 deletion docker/lib/generate-risk-report.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------------------
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -294,6 +353,28 @@ HTMLSEC
else
echo "<p>식별된 distinct 라이선스: <b>${LIC_COUNT}</b>건 (상세는 NOTICE 산출물 참조).</p>"
fi
if [ "$HAS_LIC_CLASS" = "true" ]; then
echo "<h3>라이선스 분류 (카피레프트 강도)</h3>"
echo "<p>각 컴포넌트는 SBOM에 <code>bomlens:licenseClass</code> 속성으로도 기록되어 있습니다. 인식되지 않은 라이선스는 permissive로 간주하지 않고 미분류(uncategorized)로 남깁니다.</p>"
cat <<HTMLLIC
<div class="cards">
<span class="pill pill-crit">Network copyleft <span class="count">${NC}</span></span>
<span class="pill pill-high">Strong copyleft <span class="count">${SC}</span></span>
<span class="pill pill-med">Weak copyleft <span class="count">${WK}</span></span>
<span class="pill pill-pass">Permissive <span class="count">${PM}</span></span>
<span class="pill pill-info">미분류 <span class="count">${UN}</span></span>
</div>
HTMLLIC
if [ "$COPYLEFT_TOTAL" -gt 0 ]; then
echo "<p>카피레프트 노출을 만드는 컴포넌트 (network/strong, 최대 10개):</p>"
echo "<ul class=\"mono\">"
echo "$COPYLEFT_TOP" | jq -r '.[] | "<li>" + (.label|@html) + " (" + .class + ")</li>"'
if [ "$COPYLEFT_TOTAL" -gt 10 ]; then
echo "<li>외 $((COPYLEFT_TOTAL - 10))개 (전체는 SBOM의 <code>bomlens:licenseClass</code> 속성 참조)</li>"
fi
echo "</ul>"
fi
fi

echo "<h2>${S_NEXT}. 다음 단계</h2>"
echo "<ol>"
Expand All @@ -307,4 +388,4 @@ HTMLSEC
echo "</body></html>"
} > "$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})"
47 changes: 47 additions & 0 deletions docker/lib/license-flags.jq
Original file line number Diff line number Diff line change
Expand Up @@ -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;
19 changes: 18 additions & 1 deletion docker/lib/normalize-sbom.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions docs/concepts/reports-explained.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/concepts/reports-explained.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions tests/snapshots/normalize-license-aliases.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
}
],
"name": "coverage",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"type": "library",
"version": "7.4.0"
},
Expand All @@ -23,6 +29,12 @@
}
],
"name": "flask",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"type": "library",
"version": "3.0.0"
},
Expand All @@ -35,6 +47,12 @@
}
],
"name": "mccabe",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"type": "library",
"version": "0.7.0"
},
Expand All @@ -45,6 +63,12 @@
}
],
"name": "packaging",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "uncategorized"
}
],
"type": "library",
"version": "23.0"
},
Expand All @@ -55,6 +79,12 @@
}
],
"name": "python-dateutil",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "uncategorized"
}
],
"type": "library",
"version": "2.9.0"
},
Expand All @@ -67,6 +97,12 @@
}
],
"name": "requests",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"type": "library",
"version": "2.31.0"
},
Expand All @@ -79,6 +115,12 @@
}
],
"name": "six",
"properties": [
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"type": "library",
"version": "1.16.0"
}
Expand Down
24 changes: 24 additions & 0 deletions tests/snapshots/vendored-merged-normalized.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
{
"name": "bomlens:layer",
"value": "trelay"
},
{
"name": "bomlens:licenseClass",
"value": "uncategorized"
}
],
"purl": "pkg:generic/config.c",
Expand All @@ -18,6 +22,10 @@
{
"name": "bomlens:layer",
"value": "trelay"
},
{
"name": "bomlens:licenseClass",
"value": "uncategorized"
}
],
"purl": "pkg:generic/lfds.c",
Expand All @@ -29,6 +37,10 @@
{
"name": "bomlens:layer",
"value": "trelay"
},
{
"name": "bomlens:licenseClass",
"value": "uncategorized"
}
],
"purl": "pkg:generic/main.c",
Expand All @@ -40,6 +52,10 @@
{
"name": "bomlens:layer",
"value": "trelay"
},
{
"name": "bomlens:licenseClass",
"value": "uncategorized"
}
],
"purl": "pkg:generic/ssl_lib.c",
Expand Down Expand Up @@ -78,6 +94,10 @@
{
"name": "bomlens:layer",
"value": "layer-1"
},
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"purl": "pkg:github/liblfds/liblfds",
Expand Down Expand Up @@ -118,6 +138,10 @@
{
"name": "bomlens:layer",
"value": "layer-1"
},
{
"name": "bomlens:licenseClass",
"value": "permissive"
}
],
"purl": "pkg:github/openssl/openssl",
Expand Down
Loading
Loading