From 4cf50d99bf2e3ce2f0c9d1c3fd206696296741df Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:10:36 +0530 Subject: [PATCH 1/2] fix: validate release SBOM semantics --- .github/workflows/tests.yml | 19 ++++++++ docs/release-process.md | 9 ++++ scripts/release-artifact | 13 +++++- tests/fixtures/spdx/missing-sha1.spdx.json | 41 +++++++++++++++++ tests/release-artifact.bats | 2 + tests/validate-spdx.py | 51 ++++++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/spdx/missing-sha1.spdx.json create mode 100644 tests/validate-spdx.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3effc1b..0192736 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,25 @@ jobs: - name: Validate repository run: ./tests/validate.sh + - name: Install pinned SPDX validator (Linux) + if: runner.os == 'Linux' + run: | + python3 -m venv "$RUNNER_TEMP/spdx-tools" + "$RUNNER_TEMP/spdx-tools/bin/python" -m pip install --disable-pip-version-check 'spdx-tools==0.8.5' + + - name: Validate SPDX release artifact independently (Linux) + if: runner.os == 'Linux' + run: | + output="$RUNNER_TEMP/spdx-validation-artifact" + scripts/release-artifact build \ + --version 2.2.0 \ + --commit "$(git rev-parse --verify 'HEAD^{commit}')" \ + --output "$output" + "$RUNNER_TEMP/spdx-tools/bin/python" tests/validate-spdx.py \ + "$output/base-bash-libs-v2.2.0.spdx.json" + "$RUNNER_TEMP/spdx-tools/bin/python" tests/validate-spdx.py \ + --expect-missing-sha1 tests/fixtures/spdx/missing-sha1.spdx.json + - name: Smoke supported runner Bash run: | bash -c ' diff --git a/docs/release-process.md b/docs/release-process.md index fae01f4..844d61b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -108,6 +108,15 @@ policy](https://github.com/basefoundry/base/blob/main/docs/ecosystem-policy.md). 9. Verify the annotated `vX.Y.Z` tag and the GitHub Release for `basefoundry/base-bash-libs`. +## SBOM Interoperability + +The generated SPDX 2.3 SBOM retains SHA256 and includes the SHA1 checksum +required by SPDX 2.3 file records. The Linux validation job independently parses +and semantically validates each candidate SBOM with `spdx-tools==0.8.5`, and +checks a parseable negative fixture that is rejected specifically for missing +SHA1. This validation does not rewrite or replace any already-published release +asset; corrected SBOMs are produced only for a future authorized release. + ## Homebrew Handoff The release contract requires the tap-owned formula diff --git a/scripts/release-artifact b/scripts/release-artifact index 5ad8dbd..7a6e389 100755 --- a/scripts/release-artifact +++ b/scripts/release-artifact @@ -45,6 +45,14 @@ hash_file() { fi } +hash_file_sha1() { + if command -v sha1sum > /dev/null 2>&1; then + sha1sum -- "$1" | awk '{print $1}' + else + shasum -a 1 -- "$1" | awk '{print $1}' + fi +} + json_escape() { local value="${1-}" value=${value//\\/\\\\} @@ -158,8 +166,9 @@ write_sbom() { printf ',\n' fi first=0 - printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ - "$(spdx_file_identifier "$relative")" "$(json_escape "$relative")" "$(hash_file "$path")" + printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA1", "checksumValue": "%s"}, {"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ + "$(spdx_file_identifier "$relative")" "$(json_escape "$relative")" \ + "$(hash_file_sha1 "$path")" "$(hash_file "$path")" done < <(find "$root" -type f -print | LC_ALL=C sort) printf '\n ],\n' printf ' "relationships": [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package"}]\n' diff --git a/tests/fixtures/spdx/missing-sha1.spdx.json b/tests/fixtures/spdx/missing-sha1.spdx.json new file mode 100644 index 0000000..cc8631e --- /dev/null +++ b/tests/fixtures/spdx/missing-sha1.spdx.json @@ -0,0 +1,41 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "base-bash-libs-semantic-negative-fixture", + "documentNamespace": "https://example.invalid/base-bash-libs/semantic-negative-fixture", + "creationInfo": { + "created": "2026-01-01T00:00:00Z", + "creators": ["Tool: base-bash-libs-test"] + }, + "packages": [ + { + "SPDXID": "SPDXRef-Package", + "name": "fixture", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": true, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION" + } + ], + "files": [ + { + "SPDXID": "SPDXRef-File-fixture", + "fileName": "fixture.sh", + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "licenseConcluded": "NOASSERTION" + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package" + } + ] +} diff --git a/tests/release-artifact.bats b/tests/release-artifact.bats index c0386a7..6036431 100644 --- a/tests/release-artifact.bats +++ b/tests/release-artifact.bats @@ -104,6 +104,8 @@ EOF [ "$status" -eq 0 ] diff -ru "$first" "$second" grep -F '"spdxVersion": "SPDX-2.3"' "$first"/*.spdx.json + grep -F '"algorithm": "SHA1"' "$first"/*.spdx.json + grep -F '"algorithm": "SHA256"' "$first"/*.spdx.json grep -F '"reproducible": true' "$first"/*.provenance.json awk '/"SPDXID": "SPDXRef-File-/ { id=$0; sub(/^.*"SPDXID": "/, "", id); sub(/".*$/, "", id); if (id !~ /^SPDXRef-File-[A-Za-z0-9.-]+$/ || seen[id]++) exit 1; count++ } END { exit (count > 0 ? 0 : 1) }' \ "$first"/*.spdx.json diff --git a/tests/validate-spdx.py b/tests/validate-spdx.py new file mode 100644 index 0000000..254c5ca --- /dev/null +++ b/tests/validate-spdx.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Validate SPDX JSON with the pinned independent spdx-tools package.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from spdx_tools.spdx.parser.parse_anything import parse_file +from spdx_tools.spdx.validation.document_validator import validate_full_spdx_document + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("document", type=Path) + parser.add_argument( + "--expect-missing-sha1", + action="store_true", + help="pass only when semantic validation rejects a file without SHA1", + ) + arguments = parser.parse_args() + + try: + document = parse_file(str(arguments.document)) + except Exception as error: # parser errors are distinct from semantic errors + print(f"SPDX parse failed for {arguments.document}: {error}", file=sys.stderr) + return 1 + + messages = validate_full_spdx_document(document, spdx_version="SPDX-2.3") + if arguments.expect_missing_sha1: + if any("must contain a SHA1 algorithm checksum" in message.validation_message for message in messages): + print("SPDX semantic-negative fixture rejected for its missing required SHA1 checksum.") + return 0 + print("SPDX semantic-negative fixture was not rejected for its missing SHA1 checksum.", file=sys.stderr) + for message in messages: + print(message, file=sys.stderr) + return 1 + + if messages: + print(f"SPDX validation failed for {arguments.document}:", file=sys.stderr) + for message in messages: + print(message, file=sys.stderr) + return 1 + + print(f"SPDX 2.3 document is semantically valid: {arguments.document}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 36a87ea4b50de3c22cde72723d66321d46db19e8 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:14:18 +0530 Subject: [PATCH 2/2] fix(release): preserve historical SPDX checksum format --- .github/workflows/tests.yml | 6 ++---- docs/release-process.md | 15 +++++++++------ scripts/release-artifact | 31 ++++++++++++++++++++++++++++--- tests/release-artifact.bats | 23 +++++++++++++++++++++-- tests/validate-spdx.py | 12 ++++++++++-- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0192736..24b8c4a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,14 +43,12 @@ jobs: - name: Validate repository run: ./tests/validate.sh - - name: Install pinned SPDX validator (Linux) - if: runner.os == 'Linux' + - name: Install pinned SPDX validator run: | python3 -m venv "$RUNNER_TEMP/spdx-tools" "$RUNNER_TEMP/spdx-tools/bin/python" -m pip install --disable-pip-version-check 'spdx-tools==0.8.5' - - name: Validate SPDX release artifact independently (Linux) - if: runner.os == 'Linux' + - name: Validate SPDX release artifact independently run: | output="$RUNNER_TEMP/spdx-validation-artifact" scripts/release-artifact build \ diff --git a/docs/release-process.md b/docs/release-process.md index 844d61b..3315462 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -110,12 +110,15 @@ policy](https://github.com/basefoundry/base/blob/main/docs/ecosystem-policy.md). ## SBOM Interoperability -The generated SPDX 2.3 SBOM retains SHA256 and includes the SHA1 checksum -required by SPDX 2.3 file records. The Linux validation job independently parses -and semantically validates each candidate SBOM with `spdx-tools==0.8.5`, and -checks a parseable negative fixture that is rejected specifically for missing -SHA1. This validation does not rewrite or replace any already-published release -asset; corrected SBOMs are produced only for a future authorized release. +The SPDX 2.3 SBOM format is versioned with the release: releases before v2.2.0 +retain their published SHA256-only file records, while v2.2.0 and later include +both SHA256 and the SHA1 checksum required by SPDX 2.3 file records. The Linux +and macOS validation jobs independently parse and semantically validate each +candidate SBOM with `spdx-tools==0.8.5`; macOS also exercises the `shasum -a 1` +checksum path. Both jobs check a parseable negative fixture that must produce +exactly one semantic error, specifically the missing SHA1 checksum. This +validation does not rewrite or replace any already-published release asset; +corrected SBOMs are produced only for a future authorized release. ## Homebrew Handoff diff --git a/scripts/release-artifact b/scripts/release-artifact index 7a6e389..c942587 100755 --- a/scripts/release-artifact +++ b/scripts/release-artifact @@ -146,8 +146,25 @@ normalize_tree_times() { done < <(find "$root" -type d -print) } +spdx_sbom_requires_sha1() { + local version="${1-}" major minor version_tail + + major="${version%%.*}" + version_tail="${version#*.}" + minor="${version_tail%%.*}" + [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]] || return 2 + ((10#$major > 2 || (10#$major == 2 && 10#$minor >= 2))) +} + write_sbom() { local output="$1" version="$2" commit="$3" root="$4" path relative first=1 + local include_sha1=0 status file_id file_name sha1_hash sha256_hash + if spdx_sbom_requires_sha1 "$version"; then + include_sha1=1 + else + status=$? + ((status == 1)) || return "$status" + fi { printf '{\n' printf ' "spdxVersion": "SPDX-2.3",\n' @@ -166,9 +183,17 @@ write_sbom() { printf ',\n' fi first=0 - printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA1", "checksumValue": "%s"}, {"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ - "$(spdx_file_identifier "$relative")" "$(json_escape "$relative")" \ - "$(hash_file_sha1 "$path")" "$(hash_file "$path")" + file_id="$(spdx_file_identifier "$relative")" || return 1 + file_name="$(json_escape "$relative")" + sha256_hash="$(hash_file "$path")" || return 1 + if ((include_sha1)); then + sha1_hash="$(hash_file_sha1 "$path")" || return 1 + printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA1", "checksumValue": "%s"}, {"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ + "$file_id" "$file_name" "$sha1_hash" "$sha256_hash" + else + printf ' {"SPDXID": "%s", "fileName": "%s", "checksums": [{"algorithm": "SHA256", "checksumValue": "%s"}], "licenseConcluded": "Apache-2.0"}' \ + "$file_id" "$file_name" "$sha256_hash" + fi done < <(find "$root" -type f -print | LC_ALL=C sort) printf '\n ],\n' printf ' "relationships": [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package"}]\n' diff --git a/tests/release-artifact.bats b/tests/release-artifact.bats index 6036431..96198b0 100644 --- a/tests/release-artifact.bats +++ b/tests/release-artifact.bats @@ -94,23 +94,42 @@ EOF @test "release artifact build creates a deterministic verified asset set" { local first="$TEST_TMPDIR/first" second="$TEST_TMPDIR/second" - bats_run "$RELEASE_ARTIFACT" build --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$first" + bats_run "$RELEASE_ARTIFACT" build --version 2.2.0-rc.1 --commit "$RELEASE_COMMIT" --output "$first" [ "$status" -eq 0 ] bats_run "$RELEASE_ARTIFACT" verify "$first" [ "$status" -eq 0 ] [[ "$output" == *"verified"* ]] - bats_run "$RELEASE_ARTIFACT" build --version 2.0.0-rc.1 --commit "$RELEASE_COMMIT" --output "$second" + bats_run "$RELEASE_ARTIFACT" build --version 2.2.0-rc.1 --commit "$RELEASE_COMMIT" --output "$second" [ "$status" -eq 0 ] diff -ru "$first" "$second" grep -F '"spdxVersion": "SPDX-2.3"' "$first"/*.spdx.json grep -F '"algorithm": "SHA1"' "$first"/*.spdx.json grep -F '"algorithm": "SHA256"' "$first"/*.spdx.json + awk '/"SPDXID": "SPDXRef-File-/ { count++; if ($0 !~ /"algorithm": "SHA1"/ || $0 !~ /"algorithm": "SHA256"/) missing++ } END { exit (count > 0 && missing == 0 ? 0 : 1) }' \ + "$first"/*.spdx.json grep -F '"reproducible": true' "$first"/*.provenance.json awk '/"SPDXID": "SPDXRef-File-/ { id=$0; sub(/^.*"SPDXID": "/, "", id); sub(/".*$/, "", id); if (id !~ /^SPDXRef-File-[A-Za-z0-9.-]+$/ || seen[id]++) exit 1; count++ } END { exit (count > 0 ? 0 : 1) }' \ "$first"/*.spdx.json } +@test "legacy 2.1 SBOM verification retains its SHA256-only file records" { + local artifact="$TEST_TMPDIR/legacy" sbom verified="$TEST_TMPDIR/verified" + + release_test_build "$artifact" 2.1.0 + sbom="$artifact/base-bash-libs-v2.1.0.spdx.json" + awk '/"SPDXID": "SPDXRef-File-/ { count++; if ($0 ~ /"algorithm": "SHA1"/ || $0 !~ /"algorithm": "SHA256"/) invalid++ } END { exit (count > 0 && invalid == 0 ? 0 : 1) }' \ + "$sbom" + + export REMOTE_VERSION=2.1.0 REMOTE_COMMIT="$RELEASE_COMMIT" REMOTE_SOURCE="$artifact" + export REMOTE_TAG_OBJECT=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb REMOTE_ASSET_MODE=full + release_test_make_remote_gh_stub + bats_run "$RELEASE_ARTIFACT" verify-remote --version "$REMOTE_VERSION" \ + --commit "$REMOTE_COMMIT" --output "$verified" + [ "$status" -eq 0 ] + [[ "$output" == *"GitHub Release assets verified"* ]] +} + @test "release SBOM file identifiers are collision-resistant and reject the former underscore form" { local artifact="$TEST_TMPDIR/artifact" sbom sums temporary diff --git a/tests/validate-spdx.py b/tests/validate-spdx.py index 254c5ca..b8835a5 100644 --- a/tests/validate-spdx.py +++ b/tests/validate-spdx.py @@ -29,10 +29,18 @@ def main() -> int: messages = validate_full_spdx_document(document, spdx_version="SPDX-2.3") if arguments.expect_missing_sha1: - if any("must contain a SHA1 algorithm checksum" in message.validation_message for message in messages): + sha1_errors = [ + message + for message in messages + if "must contain a SHA1 algorithm checksum" in message.validation_message + ] + if len(messages) == 1 and len(sha1_errors) == 1: print("SPDX semantic-negative fixture rejected for its missing required SHA1 checksum.") return 0 - print("SPDX semantic-negative fixture was not rejected for its missing SHA1 checksum.", file=sys.stderr) + print( + "SPDX semantic-negative fixture must produce exactly one validation error, for its missing SHA1 checksum.", + file=sys.stderr, + ) for message in messages: print(message, file=sys.stderr) return 1