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
26 changes: 3 additions & 23 deletions docker/lib/generate-notice.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,29 +32,9 @@ TXT="${OUT_PREFIX}_NOTICE.txt"
HTML="${OUT_PREFIX}_NOTICE.html"

# Normalize a license id/name/expression to an SPDX id for common aliases.
# A genuine compound expression ("X OR Y") is left untouched; LGPL/GPL "or later"
# is matched before the compound check so it is not mistaken for a compound.
NORMALIZE_DEF='
def normalize($s):
($s | ascii_downcase | gsub("[ ,._/-]+"; " ") | sub("^ +";"") | sub(" +$";"")) as $n |
if ($n | test("(lesser|library) general public.*2 1.*later")) then "LGPL-2.1-or-later"
elif ($n | test("(lesser|library) general public.*2 1")) then "LGPL-2.1-only"
elif ($n | test("(lesser|library) general public.*3.*later")) then "LGPL-3.0-or-later"
elif ($n | test("(lesser|library) general public.*3")) then "LGPL-3.0-only"
elif ($n | test("general public.*2.*later")) then "GPL-2.0-or-later"
elif ($n | test("general public.*2 0|general public.*v2")) then "GPL-2.0-only"
elif ($n | test("general public.*3.*later")) then "GPL-3.0-or-later"
elif ($n | test("general public.*3")) then "GPL-3.0-only"
elif ($n | test(" or | and ")) then $s
elif ($n | test("apache.*2")) then "Apache-2.0"
elif ($n | test("mit license") or $n == "mit" or ($n | test("expat"))) then "MIT"
elif ($n | test("eclipse distribution") or ($n|test("^edl "))) then "BSD-3-Clause"
elif ($n | test("eclipse public.*2")) then "EPL-2.0"
elif ($n | test("eclipse public.*1")) then "EPL-1.0"
elif ($n | test("bsd.*3")) then "BSD-3-Clause"
elif ($n | test("bsd.*2")) then "BSD-2-Clause"
else $s end;
'
# The normalize() definition is shared with normalize-sbom.sh via spdx-normalize.jq
# so the NOTICE and the bom.json the web UI reads group licenses identically.
NORMALIZE_DEF="$(cat "$SCRIPT_DIR/spdx-normalize.jq")"

# Build { license, components:[ {comp, copyright} ] } grouped by normalized id.
LICENSE_MAP=$(jq -r "$NORMALIZE_DEF"'
Expand Down
29 changes: 28 additions & 1 deletion docker/lib/normalize-sbom.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ set -e
SBOM="$1"
MODE="${2:-}"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

if [ -z "$SBOM" ] || [ ! -f "$SBOM" ]; then
echo "[normalize] SBOM file not found: $SBOM" >&2
exit 1
Expand Down Expand Up @@ -43,6 +45,29 @@ 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)'

# 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
# raw, so the same license splits into several buckets. normalize() (shared with
# generate-notice.sh) maps only recognized aliases — a non-alias string and a
# valid-but-wrong upstream id (e.g. cdxgen mislabeling a package 0BSD) are left
# as-is rather than guessed. Free text that maps to a single SPDX id is promoted
# from .license.name / .expression to a proper .license.id; the source url is kept.
NORMALIZE_DEF="$(cat "$SCRIPT_DIR/spdx-normalize.jq")"
LICENSE_FIX='(.components) |= (if type=="array" then map(
if (.licenses|type)=="array" then
.licenses |= map(
if (has("expression") and (.expression|type)=="string") then
(normalize(.expression)) as $n |
(if $n != .expression then {license:{id:$n}} else . end)
elif ((.license|type)=="object" and (.license.id == null) and ((.license.name // null) != null)) then
.license |= (normalize(.name) as $n |
(if $n != .name then ({id:$n} + (if .url then {url:.url} else {} end)) else . end))
else . end
)
else . end
) 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 @@ -51,8 +76,10 @@ if [ "$MODE" = "--stable" ]; then
# 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 "
${NORMALIZE_DEF}
${NULL_FIX}
| ${PURL_FIX}
| ${LICENSE_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 @@ -64,7 +91,7 @@ if [ "$MODE" = "--stable" ]; then
| del(.serialNumber)
" "$SBOM" > "$TMP"
else
jq -S "${NULL_FIX} | ${PURL_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP"
jq -S "${NORMALIZE_DEF} ${NULL_FIX} | ${PURL_FIX} | ${LICENSE_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP"
fi

mv "$TMP" "$SBOM"
Expand Down
30 changes: 30 additions & 0 deletions docker/lib/spdx-normalize.jq
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# spdx-normalize.jq — map a license id/name/expression to an SPDX id for common
# aliases. Single source of truth shared by generate-notice.sh (NOTICE grouping)
# and normalize-sbom.sh (bom.json component licenses), so the attribution NOTICE
# and the web UI surfaces (license filter, distribution card, dependency tree)
# agree on the same canonical id.
#
# A genuine compound expression ("X OR Y") is left untouched; LGPL/GPL "or later"
# is matched before the compound check so it is not mistaken for a compound. An
# unrecognized string is returned unchanged, so a valid-but-wrong SPDX id from the
# upstream tool (e.g. cdxgen FETCH_LICENSE marking a package 0BSD) is preserved
# rather than silently rewritten to a guess.
def normalize($s):
($s | ascii_downcase | gsub("[ ,._/-]+"; " ") | sub("^ +";"") | sub(" +$";"")) as $n |
if ($n | test("(lesser|library) general public.*2 1.*later")) then "LGPL-2.1-or-later"
elif ($n | test("(lesser|library) general public.*2 1")) then "LGPL-2.1-only"
elif ($n | test("(lesser|library) general public.*3.*later")) then "LGPL-3.0-or-later"
elif ($n | test("(lesser|library) general public.*3")) then "LGPL-3.0-only"
elif ($n | test("general public.*2.*later")) then "GPL-2.0-or-later"
elif ($n | test("general public.*2 0|general public.*v2")) then "GPL-2.0-only"
elif ($n | test("general public.*3.*later")) then "GPL-3.0-or-later"
elif ($n | test("general public.*3")) then "GPL-3.0-only"
elif ($n | test(" or | and ")) then $s
elif ($n | test("apache.*2")) then "Apache-2.0"
elif ($n | test("mit license") or $n == "mit" or ($n | test("expat"))) then "MIT"
elif ($n | test("eclipse distribution") or ($n|test("^edl "))) then "BSD-3-Clause"
elif ($n | test("eclipse public.*2")) then "EPL-2.0"
elif ($n | test("eclipse public.*1")) then "EPL-1.0"
elif ($n | test("bsd.*3")) then "BSD-3-Clause"
elif ($n | test("bsd.*2")) then "BSD-2-Clause"
else $s end;
56 changes: 37 additions & 19 deletions docker/web/frontend/src/components/InputTypeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";

import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SOURCE_TYPES, type SourceType } from "@/lib/api";
import { cn } from "@/lib/utils";

interface Props {
value: SourceType;
Expand All @@ -20,6 +20,11 @@ const LABEL_KEY: Record<SourceType, string> = {
"docker-image": "source.dockerImage",
};

// A segmented selector. Previously this used the Tabs primitive, but Tabs without
// TabsContent panels emits an aria-controls pointing at a tabpanel that is never
// rendered, which fails axe aria-valid-attr-value (V13-3/F3). These are mutually
// exclusive options with no associated panel, so a labelled group of aria-pressed
// toggle buttons is the accessible primitive.
export function InputTypeSelector({
value,
onChange,
Expand All @@ -28,23 +33,36 @@ export function InputTypeSelector({
}: Props) {
const { t } = useTranslation();
return (
<Tabs value={value} onValueChange={(v) => onChange(v as SourceType)}>
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-3">
{SOURCE_TYPES.map((s) => {
const fwLocked = s === "firmware-upload" && firmwareDisabled;
return (
<TabsTrigger
key={s}
value={s}
disabled={disabled || fwLocked}
title={fwLocked ? t("source.firmwareUnavailable") : undefined}
className="px-2 py-1.5 text-xs"
>
{t(LABEL_KEY[s])}
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
<div
role="group"
aria-label={t("source.label")}
className="grid w-full grid-cols-2 gap-1 rounded-lg bg-muted p-1 sm:grid-cols-3"
>
{SOURCE_TYPES.map((s) => {
const fwLocked = s === "firmware-upload" && firmwareDisabled;
const active = value === s;
return (
<button
key={s}
type="button"
aria-pressed={active}
disabled={disabled || fwLocked}
title={fwLocked ? t("source.firmwareUnavailable") : undefined}
onClick={() => onChange(s)}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 py-1.5 text-xs font-medium",
"ring-offset-background transition-all duration-fast ease-out-soft",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(LABEL_KEY[s])}
</button>
);
})}
</div>
);
}
8 changes: 7 additions & 1 deletion docker/web/frontend/src/components/ProgressLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ export function ProgressLog({ logs, status }: Props) {
<CardContent className="space-y-3">
<Progress
value={value}
aria-label={t("progress.title")}
indicatorClassName={cn(
status === "error" && "bg-destructive",
status === "done" && "bg-emerald-500",
)}
/>
<div className="h-72 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
<div
role="log"
aria-label={t("progress.title")}
tabIndex={0}
className="h-72 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{logs.length === 0 ? (
<p className="text-muted-foreground">{t("progress.waiting")}</p>
) : (
Expand Down
11 changes: 11 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ exclude_docs: |
internal/
korean-style-guide.md

# Validate internal links and heading anchors against the rendered site. Anchors
# are checked with this config's own pymdownx slugify, so the result matches what
# the site actually serves (a generic GitHub-style checker disagrees on slugs that
# contain a removed symbol such as an em-dash). With `mkdocs build --strict` in CI,
# a link to a missing page or a stale heading anchor fails the build.
validation:
omitted_files: warn
absolute_links: warn
unrecognized_links: warn
anchors: warn

nav:
- Home: index.md
- Get started:
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/license-aliases.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
{
"type": "library", "name": "six", "version": "1.16.0",
"licenses": [ { "license": { "id": "MIT" } } ]
},
{
"type": "library", "name": "flask", "version": "3.0.0",
"licenses": [ { "license": { "id": "0BSD", "url": "https://opensource.org/licenses/0BSD" } } ]
},
{
"type": "library", "name": "python-dateutil", "version": "2.9.0",
"licenses": [ { "expression": "Dual License" } ]
}
]
}
20 changes: 20 additions & 0 deletions tests/test-postprocess.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ else
fail "generate-notice.sh did not produce $NOTICE"
fi

echo "== V13-2: normalize-sbom.sh maps bom.json license aliases to SPDX ids =="
cp "$FIX/license-aliases.json" "$WORK/c.json"
bash "$LIB/normalize-sbom.sh" "$WORK/c.json" >/dev/null 2>&1
# Free-text alias in .expression is promoted to a proper .license.id.
mccabe_id=$(jq -r '.components[] | select(.name=="mccabe") | .licenses[0].license.id // "ABSENT"' "$WORK/c.json")
[ "$mccabe_id" = "MIT" ] && pass "Expat expression promoted to license id MIT" || fail "mccabe license id='$mccabe_id', expected MIT"
# Free-text alias in .license.name is promoted as well.
cov_id=$(jq -r '.components[] | select(.name=="coverage") | .licenses[0].license.id // "ABSENT"' "$WORK/c.json")
[ "$cov_id" = "Apache-2.0" ] && pass "free-text license name promoted to id Apache-2.0" || fail "coverage license id='$cov_id', expected Apache-2.0"
# A valid-but-wrong upstream id (cdxgen 0BSD mislabel) is preserved, not guessed.
flask_id=$(jq -r '.components[] | select(.name=="flask") | .licenses[0].license.id // "ABSENT"' "$WORK/c.json")
flask_url=$(jq -r '.components[] | select(.name=="flask") | .licenses[0].license.url // "ABSENT"' "$WORK/c.json")
[ "$flask_id" = "0BSD" ] && pass "valid-but-wrong upstream id (0BSD) preserved, not rewritten" || fail "flask license id='$flask_id', expected 0BSD"
[ "$flask_url" = "https://opensource.org/licenses/0BSD" ] && pass "license url preserved" || fail "flask license url='$flask_url'"
# A non-mappable free-text string and a genuine compound expression are untouched.
date_expr=$(jq -r '.components[] | select(.name=="python-dateutil") | .licenses[0].expression // "ABSENT"' "$WORK/c.json")
[ "$date_expr" = "Dual License" ] && pass "unmappable free text (Dual License) left untouched" || fail "dateutil expression='$date_expr', expected Dual License"
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 ""
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ]
Loading