From fe0b20e9da5a2b0a00c3899b56d9f70fc9c108d0 Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Fri, 7 Nov 2025 17:33:51 +0000 Subject: [PATCH 1/3] Added SBOM generation and verification scripts for Java --- .evergreen/.evg.yml | 27 ++++++++ .evergreen/generate-sbom.sh | 127 ++++++++++++++++++++++++++++++++++++ .evergreen/verify-sbom.sh | 102 +++++++++++++++++++++++++++++ build.gradle.kts | 40 ++++++++++++ 4 files changed, 296 insertions(+) create mode 100755 .evergreen/generate-sbom.sh create mode 100755 .evergreen/verify-sbom.sh diff --git a/.evergreen/.evg.yml b/.evergreen/.evg.yml index c6b28f9a6f6..0bcf6b138aa 100644 --- a/.evergreen/.evg.yml +++ b/.evergreen/.evg.yml @@ -855,6 +855,19 @@ tasks: args: - ".evergreen/static-checks.sh" + - name: "verify-sbom-task" + tags: [ "sbom" ] + commands: + - command: shell.exec + type: "test" + params: + working_dir: "src" + shell: "bash" + script: | + ${PREPARE_SHELL} + echo "Verifying SBOM files" + bash .evergreen/verify-sbom.sh + - name: "test-bson-and-crypt-task" commands: - func: "run-tests" @@ -2231,6 +2244,20 @@ buildvariants: tasks: - name: "static-analysis-task" + - name: "verify-sbom" + display_name: "Verify SBOM freshness" + run_on: ubuntu2204-small + # Only trigger when dependency manifests or the SBOM itself change. + paths: + - "gradle/libs.versions.toml" + - "gradle.properties" + - "settings.gradle.kts" + - "build.gradle.kts" + - "**/build.gradle.kts" + - "sbom.json" + tasks: + - name: "verify-sbom-task" + - name: "perf" display_name: "Performance Tests" tags: [ "perf-variant" ] diff --git a/.evergreen/generate-sbom.sh b/.evergreen/generate-sbom.sh new file mode 100755 index 00000000000..d62e29ef845 --- /dev/null +++ b/.evergreen/generate-sbom.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ephemeral SBOM generator (Gradle/Java) using mise + CycloneDX Gradle plugin. +# Environment overrides: +# MISE_JAVA_VERSION Java (Temurin) major version (default from Gradle sourceCompatibility or 21) +# SBOM_OUT Output filename (default sbom.json) +# +# Usage: bash .evergreen/generate-sbom.sh + +## resolve_java_version +# Determines the required Java version by finding the maximum sourceCompatibility in Gradle files. +resolve_java_version() { + local max_version + max_version=$(find . -name "*.gradle.kts" -exec grep -h 'sourceCompatibility = JavaVersion.VERSION_' {} \; | sed 's/.*VERSION_\([0-9]*\).*/\1/' | sort -n | tail -1) + echo "${max_version:-21}" +} + +JAVA_VERSION="${MISE_JAVA_VERSION:-$(resolve_java_version)}" +JQ_VERSION="${JQ_VERSION:-latest}" # jq version or 'latest' +OUT_JSON="${SBOM_OUT:-sbom.json}" + +log() { printf '\n[sbom] %s\n' "$*"; } + +# Ensure mise is available (installed locally in $HOME) and PATH includes shims. + +ensure_mise() { + # Installer places binary in ~/.local/bin/mise by default. + if ! command -v mise >/dev/null 2>&1; then + log "Installing mise" + curl -fsSL https://mise.run | bash >/dev/null 2>&1 || { log "mise install script failed"; exit 1; } + fi + # Ensure ~/.local/bin precedes so 'mise' is found even if shims absent. + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$HOME/.local/share/mise/bin:$PATH" + if ! command -v mise >/dev/null 2>&1; then + log "mise not found on PATH after install"; ls -al "$HOME/.local/bin" || true; exit 1 + fi +} + +## resolve_toolchain_flags +# Returns space-separated tool@version specs required for SBOM generation. +resolve_toolchain_flags() { + printf 'java@temurin-%s jq@%s' "$JAVA_VERSION" "$JQ_VERSION" +} + +## prepare_exec_prefix +# Builds the mise exec prefix for ephemeral command runs. +prepare_exec_prefix() { + local tools + tools="$(resolve_toolchain_flags)" + echo "mise exec $tools --" +} + +## generate_sbom +# Executes Gradle CycloneDX plugin to generate SBOM. +generate_sbom() { + log "Generating SBOM using Gradle CycloneDX plugin" + local exec_prefix + exec_prefix="$(prepare_exec_prefix)" + $exec_prefix ./gradlew cyclonedxBom || { + log "SBOM generation failed"; exit 1; } + log "SBOM generated" +} + +## install_toolchains +# Installs required runtime versions into the local mise cache unconditionally. +# (mise skips download if already present.) +install_toolchains() { + local tools + tools="$(resolve_toolchain_flags)" + log "Installing toolchains: $tools" + mise install $tools >/dev/null +} + +## format_sbom +# Formats the SBOM JSON with jq (required). Exits non-zero if formatting fails. +format_sbom() { + log "Formatting SBOM via jq@$JQ_VERSION" + if ! mise exec jq@"$JQ_VERSION" -- jq . "$OUT_JSON" > "$OUT_JSON.tmp" 2>/dev/null; then + log "jq formatting failed"; return 1 + fi + mv "$OUT_JSON.tmp" "$OUT_JSON" +} + +## ensure_cyclonedx_cli +# Downloads CycloneDX CLI binary if not available. +ensure_cyclonedx_cli() { + if [ ! -f /tmp/cyclonedx ]; then + log "Downloading CycloneDX CLI" + local arch + arch="$(uname -m)" + case "$arch" in + x86_64) arch="x64" ;; + aarch64) arch="arm64" ;; + *) log "Unsupported architecture for CycloneDX CLI: $arch"; exit 1 ;; + esac + local url="https://github.com/CycloneDX/cyclonedx-cli/releases/latest/download/cyclonedx-linux-${arch}" + curl -L -s -o /tmp/cyclonedx "$url" || { log "Failed to download CycloneDX CLI"; exit 1; } + chmod +x /tmp/cyclonedx || { log "Failed to make CycloneDX CLI executable"; exit 1; } + fi +} + +## verify_sbom +# Verifies the SBOM is valid CycloneDX format using CycloneDX CLI. +verify_sbom() { + log "Verifying SBOM validity with CycloneDX CLI" + local size + size=$(stat -c%s "$OUT_JSON" 2>/dev/null || echo 0) + if [ "$size" -lt 1000 ]; then + log "SBOM file too small (<1000 bytes)"; exit 1 + fi + if ! /tmp/cyclonedx validate --input-file "$OUT_JSON" --fail-on-errors >/dev/null 2>&1; then + log "SBOM validation failed"; exit 1 + fi + log "SBOM verified successfully" +} + +main() { + ensure_mise + install_toolchains + generate_sbom + format_sbom + ensure_cyclonedx_cli + verify_sbom +} + +main "$@" \ No newline at end of file diff --git a/.evergreen/verify-sbom.sh b/.evergreen/verify-sbom.sh new file mode 100755 index 00000000000..6252b654da7 --- /dev/null +++ b/.evergreen/verify-sbom.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail +# verify-sbom.sh: Enforces policy that when dependency manifest files change, the SBOM file must also change. +# Generic so patterns can be repurposed for other artifact freshness checks. +# +# Configurable variables (override via env): +# MANIFEST_PATTERNS Space-delimited gitignore-style patterns to monitor. +# Default: gradle/libs.versions.toml gradle.properties settings.gradle.kts **/build.gradle.kts +# SBOM_FILE Path to canonical SBOM file (default sbom.cdx.json) +# EXIT_CODE_MISSING Exit code when manifests changed but SBOM did not (default 10) +# SKIP_SBOM_VERIFY If set to 1, skip verification (default unset) +# DIFF_BASE Explicit git ref/commit to diff against (optional). If unset, merge-base with origin/main or HEAD~1 fallback. +# VERBOSE If set to 1, prints expanded manifest list and full diff file list. +# +# Behavior: +# 1. Determine diff range (patch vs mainline) by attempting merge-base with origin/main. +# 2. Capture changed files including both committed changes and staged/uncommitted (by combining git diff --name-status for range and working tree). +# 3. Expand manifest patterns via git ls-files. +# 4. If any manifest changed but SBOM_FILE not changed, fail with guidance. +# 5. If SBOM_FILE changed, pass. +# 6. If no manifest changed, pass. +# +# Local usage: run from repo root after making changes; exit codes surface policy status. + +MANIFEST_PATTERNS="${MANIFEST_PATTERNS:-build.gradle.kts gradle/libs.versions.toml gradle.properties settings.gradle.kts **/build.gradle.kts}" +SBOM_FILE="${SBOM_FILE:-sbom.json}" +EXIT_CODE_MISSING="${EXIT_CODE_MISSING:-10}" +DIFF_BASE="${DIFF_BASE:-}" # optional user-provided base ref +VERBOSE="${VERBOSE:-0}" + +log() { printf '\n[verify-sbom] %s\n' "$*"; } + +if [[ "${SKIP_SBOM_VERIFY:-}" == "1" ]]; then + log "Skipping verification (SKIP_SBOM_VERIFY=1)"; exit 0; fi + +# Determine base for diff +if [[ -n "$DIFF_BASE" ]]; then + base_ref="$DIFF_BASE" +else + git fetch origin main >/dev/null 2>&1 || true + base_ref="$(git merge-base HEAD origin/main 2>/dev/null || echo '')" + if [[ -z "$base_ref" ]]; then + # Fallback to previous commit + base_ref="HEAD~1" + fi +fi + +range="$base_ref..HEAD" +log "Using diff range: $range" + +# Committed diff +committed_status="$(git diff --name-status $range || true)" +# Working tree (staged + unstaged) diff vs HEAD +wt_status="$(git diff --name-status HEAD || true)" +# Combine and extract filenames (second column); preserve uniqueness +changed_files=$(printf "%s\n%s\n" "$committed_status" "$wt_status" | awk '{print $2}' | grep -v '^$' | sort -u) + +if [[ $VERBOSE == 1 ]]; then + log "Changed files:"; echo "$changed_files" +fi + +# Expand manifest patterns to tracked files +expanded_manifests="" +for pattern in $MANIFEST_PATTERNS; do + # git ls-files supports pathspec; '**' requires extglob-like; rely on grep fallback + matches=$(git ls-files "$pattern" 2>/dev/null || true) + if [[ -z "$matches" && "$pattern" == *"**"* ]]; then + # Manual glob expansion for recursive pattern + matches=$(git ls-files | grep -E "$(echo "$pattern" | sed 's/**/.*'/ | sed 's/\./\\./g')" || true) + fi + expanded_manifests+="$matches\n" +done +expanded_manifests=$(echo -e "$expanded_manifests" | grep -v '^$' | sort -u) + +if [[ $VERBOSE == 1 ]]; then + log "Expanded manifests:"; echo "$expanded_manifests" +fi + +manifest_hit=0 +manifest_changed_list=() +while IFS= read -r mf; do + if echo "$changed_files" | grep -Fxq "$mf"; then + manifest_hit=1 + manifest_changed_list+=("$mf") + fi +done <<< "$expanded_manifests" + +if [[ $manifest_hit -eq 0 ]]; then + log "No manifest changes detected; passing." + exit 0 +fi + +if echo "$changed_files" | grep -Fxq "$SBOM_FILE"; then + log "SBOM file '$SBOM_FILE' updated alongside manifest changes; pass." + exit 0 +fi + +log "FAILURE: Manifest files changed but SBOM '$SBOM_FILE' was not modified." >&2 +log "Changed manifest(s):" >&2 +for mf in "${manifest_changed_list[@]}"; do echo " - $mf" >&2; done +log "Regenerate SBOM locally (e.g., bash .evergreen/generate-sbom.sh) and commit '$SBOM_FILE'." >&2 +exit "$EXIT_CODE_MISSING" diff --git a/build.gradle.kts b/build.gradle.kts index 3112e2c59b9..e284bce78d7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,11 +14,13 @@ * limitations under the License. */ import java.time.Duration +import org.cyclonedx.model.* plugins { id("eclipse") id("idea") alias(libs.plugins.nexus.publish) + id("org.cyclonedx.bom") version "2.3.1" } val nexusUsername: Provider = providers.gradleProperty("nexusUsername") @@ -47,3 +49,41 @@ nexusPublishing { delayBetween.set(Duration.ofSeconds(10)) } } + +tasks.cyclonedxBom { + setGroup("org.mongodb") + + // includeConfigs is the list of configuration names to include when generating the BOM (leave empty to include every configuration), regex is supported + setIncludeConfigs(listOf("runtimeClasspath","baseline")) + // skipConfigs is a list of configuration names to exclude when generating the BOM, regex is supported + //setSkipConfigs(listOf("(?i)(.*(compile|test|checkstyle|codenarc|spotbugs|detekt|analysis|zinc|dokka|commonizer|implementation|annotation).*)")) + // skipProjects is a list of project names to exclude when generating the BOM + setSkipProjects(listOf(rootProject.name, "bom")) + // Specified the type of project being built. Defaults to 'library' + setProjectType("library") + // Specified the version of the CycloneDX specification to use. Defaults to '1.5' + setSchemaVersion("1.5") + // Boms destination directory. Defaults to 'build/reports' + setDestination(project.file("./")) + // The file name for the generated BOMs (before the file format suffix). Defaults to 'bom' + setOutputName("sbom") + // The file format generated, can be xml, json or all for generating both. Defaults to 'all' + setOutputFormat("json") + + // declaration of the Object from OrganizationalContact + var organizationalContact1 = OrganizationalContact() + + // setting the Name[String], Email[String] and Phone[String] of the Object + organizationalContact1.setName("MongoDB, Inc.") + + // passing data to the plugin + setOrganizationalEntity { oe -> + oe.name = "MongoDB, Inc." + oe.urls = listOf("www.mongodb.com") + oe.addContact(organizationalContact1) + } + + setVCSGit { ref -> + ref.url = "https://github.com/mongodb/mongo-java-driver" + } +} From 4f02294f52a171f8163701c4a914f046a95dd7df Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Fri, 7 Nov 2025 18:04:45 +0000 Subject: [PATCH 2/3] Added allowed requesters for verify-sbom-task --- .evergreen/.evg.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.evergreen/.evg.yml b/.evergreen/.evg.yml index 0bcf6b138aa..46e75243ad9 100644 --- a/.evergreen/.evg.yml +++ b/.evergreen/.evg.yml @@ -856,6 +856,7 @@ tasks: - ".evergreen/static-checks.sh" - name: "verify-sbom-task" + allowed_requesters: ["patch", "github_pr"] tags: [ "sbom" ] commands: - command: shell.exec From 089640210137bf1b2e8ca447524632b08e2c0fb9 Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Fri, 7 Nov 2025 18:18:39 +0000 Subject: [PATCH 3/3] Changed to dry run and always pass for initial testing --- .evergreen/verify-sbom.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.evergreen/verify-sbom.sh b/.evergreen/verify-sbom.sh index 6252b654da7..897ff8625ea 100755 --- a/.evergreen/verify-sbom.sh +++ b/.evergreen/verify-sbom.sh @@ -99,4 +99,5 @@ log "FAILURE: Manifest files changed but SBOM '$SBOM_FILE' was not modified." >& log "Changed manifest(s):" >&2 for mf in "${manifest_changed_list[@]}"; do echo " - $mf" >&2; done log "Regenerate SBOM locally (e.g., bash .evergreen/generate-sbom.sh) and commit '$SBOM_FILE'." >&2 -exit "$EXIT_CODE_MISSING" +log "This is a dry run and will always pass." +#exit "$EXIT_CODE_MISSING"