|
| 1 | +#!/usr/bin/env bash |
| 2 | +# test-sbom-declared-deps.sh -- E2E for artifact-keeper#870. |
| 3 | +# |
| 4 | +# Proves the declared-dependency SBOM source: a Maven artifact whose POM |
| 5 | +# declares dependencies must produce a NON-EMPTY SBOM even though a scanner |
| 6 | +# cannot enumerate the (placeholder) jar. Before the #870 fix the SBOM read |
| 7 | +# path sourced components only from scanner output, so this artifact produced |
| 8 | +# an authoritative-looking "components": [] -- the silent-empty bug. |
| 9 | +# |
| 10 | +# What this asserts that the existing sbom-correctness-gate.sh (npm + lockfile, |
| 11 | +# scanner-derived) does NOT: |
| 12 | +# - declared deps from the POM appear with no scan inventory present |
| 13 | +# - a ${property}-versioned dependency is resolved (the backend reads the |
| 14 | +# POM back from object storage and interpolates against <properties>) |
| 15 | +# - a maven purl is synthesized (pkg:maven/<group>/<artifact>@<version>) |
| 16 | +# - test-scoped dependencies are excluded |
| 17 | +# - the document carries an honest completeness signal (declared/partial), |
| 18 | +# not "complete" with an empty inventory |
| 19 | +# |
| 20 | +# Environment: BASE_URL, ADMIN_USER, ADMIN_PASS, RUN_ID (see lib/common.sh). |
| 21 | + |
| 22 | +# shellcheck source=../lib/common.sh disable=SC1091 |
| 23 | +source "$(dirname "$0")/../lib/common.sh" |
| 24 | + |
| 25 | +begin_suite "sbom-declared-deps" |
| 26 | +auth_admin |
| 27 | +setup_workdir |
| 28 | + |
| 29 | +REPO_KEY="sbom-decl-${RUN_ID}" |
| 30 | +GROUP_ID="com.aktest" |
| 31 | +MVN_ARTIFACT="sbom-declared" |
| 32 | +VERSION="1.0.0" |
| 33 | +MAVEN_URL="${BASE_URL}/maven/${REPO_KEY}" |
| 34 | +GROUP_PATH=$(echo "$GROUP_ID" | tr '.' '/') |
| 35 | +BASE_PATH="${GROUP_PATH}/${MVN_ARTIFACT}/${VERSION}" |
| 36 | +JAR_REL="${BASE_PATH}/${MVN_ARTIFACT}-${VERSION}.jar" |
| 37 | +POM_REL="${BASE_PATH}/${MVN_ARTIFACT}-${VERSION}.pom" |
| 38 | + |
| 39 | +# Expected declared dependencies (resolved): |
| 40 | +GUAVA_PURL="pkg:maven/com.google.guava/guava@32.1.3-jre" |
| 41 | +COMMONS_PURL="pkg:maven/org.apache.commons/commons-lang3@3.14.0" |
| 42 | + |
| 43 | +# Resolved at runtime. |
| 44 | +ARTIFACT_ID="" |
| 45 | + |
| 46 | +cleanup_decl() { |
| 47 | + # shellcheck disable=SC2086 |
| 48 | + curl -s $CURL_TIMEOUT -X DELETE -H "$(auth_header)" \ |
| 49 | + "${BASE_URL}/api/v1/repositories/${REPO_KEY}" >/dev/null 2>&1 || true |
| 50 | + [ -n "${WORK_DIR:-}" ] && rm -rf "$WORK_DIR" 2>/dev/null || true |
| 51 | +} |
| 52 | +trap cleanup_decl EXIT |
| 53 | + |
| 54 | +# --------------------------------------------------------------------------- |
| 55 | +# Feature gate: this behaviour ships in v1.2.0 (artifact-keeper#870). On older |
| 56 | +# backends the declared-only SBOM is structurally empty, so asserting > 0 would |
| 57 | +# flap; require_feature skips loudly instead. |
| 58 | +# --------------------------------------------------------------------------- |
| 59 | +begin_test "Backend supports declared-dependency SBOM (#870)" |
| 60 | +require_feature "sbom_declared_dependencies" || { end_suite; exit 0; } |
| 61 | +pass |
| 62 | + |
| 63 | +# --------------------------------------------------------------------------- |
| 64 | +# Create repo. |
| 65 | +# --------------------------------------------------------------------------- |
| 66 | +begin_test "Create maven local repository" |
| 67 | +if create_local_repo "$REPO_KEY" "maven"; then |
| 68 | + pass |
| 69 | +else |
| 70 | + fail "could not create maven repository ${REPO_KEY}" |
| 71 | + end_suite |
| 72 | + exit 1 |
| 73 | +fi |
| 74 | + |
| 75 | +# --------------------------------------------------------------------------- |
| 76 | +# Build a placeholder JAR (a scanner finds no packages in it) and a POM that |
| 77 | +# declares two compile dependencies (one property-versioned, one literal) and |
| 78 | +# one test-scoped dependency that must be excluded. |
| 79 | +# --------------------------------------------------------------------------- |
| 80 | +begin_test "Build placeholder JAR and dependency-bearing POM" |
| 81 | +mkdir -p "${WORK_DIR}/jar/META-INF" |
| 82 | +cat > "${WORK_DIR}/jar/META-INF/MANIFEST.MF" <<EOF |
| 83 | +Manifest-Version: 1.0 |
| 84 | +Created-By: artifact-keeper-test |
| 85 | +Implementation-Title: ${MVN_ARTIFACT} |
| 86 | +Implementation-Version: ${VERSION} |
| 87 | +EOF |
| 88 | +JAR_FILE="${WORK_DIR}/${MVN_ARTIFACT}-${VERSION}.jar" |
| 89 | +( cd "${WORK_DIR}/jar" && zip -qr "$JAR_FILE" META-INF/ ) 2>/dev/null |
| 90 | + |
| 91 | +POM_FILE="${WORK_DIR}/${MVN_ARTIFACT}-${VERSION}.pom" |
| 92 | +cat > "$POM_FILE" <<EOF |
| 93 | +<?xml version="1.0" encoding="UTF-8"?> |
| 94 | +<project xmlns="http://maven.apache.org/POM/4.0.0" |
| 95 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| 96 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
| 97 | + <modelVersion>4.0.0</modelVersion> |
| 98 | + <groupId>${GROUP_ID}</groupId> |
| 99 | + <artifactId>${MVN_ARTIFACT}</artifactId> |
| 100 | + <version>${VERSION}</version> |
| 101 | + <packaging>jar</packaging> |
| 102 | + <properties> |
| 103 | + <guava.version>32.1.3-jre</guava.version> |
| 104 | + </properties> |
| 105 | + <dependencies> |
| 106 | + <dependency> |
| 107 | + <groupId>com.google.guava</groupId> |
| 108 | + <artifactId>guava</artifactId> |
| 109 | + <version>\${guava.version}</version> |
| 110 | + </dependency> |
| 111 | + <dependency> |
| 112 | + <groupId>org.apache.commons</groupId> |
| 113 | + <artifactId>commons-lang3</artifactId> |
| 114 | + <version>3.14.0</version> |
| 115 | + </dependency> |
| 116 | + <dependency> |
| 117 | + <groupId>org.junit.jupiter</groupId> |
| 118 | + <artifactId>junit-jupiter</artifactId> |
| 119 | + <version>5.10.0</version> |
| 120 | + <scope>test</scope> |
| 121 | + </dependency> |
| 122 | + </dependencies> |
| 123 | +</project> |
| 124 | +EOF |
| 125 | + |
| 126 | +if [ -f "$JAR_FILE" ] && [ -f "$POM_FILE" ]; then |
| 127 | + pass |
| 128 | +else |
| 129 | + fail "failed to build JAR or POM" |
| 130 | + end_suite |
| 131 | + exit 1 |
| 132 | +fi |
| 133 | + |
| 134 | +# --------------------------------------------------------------------------- |
| 135 | +# Upload JAR then POM via the Maven endpoint. |
| 136 | +# --------------------------------------------------------------------------- |
| 137 | +begin_test "Upload JAR" |
| 138 | +# shellcheck disable=SC2086 |
| 139 | +if curl -sf $CURL_TIMEOUT -X PUT "${MAVEN_URL}/${JAR_REL}" \ |
| 140 | + -u "${ADMIN_USER}:${ADMIN_PASS}" \ |
| 141 | + -H "Content-Type: application/java-archive" \ |
| 142 | + --data-binary "@${JAR_FILE}" >/dev/null 2>&1; then |
| 143 | + pass |
| 144 | +else |
| 145 | + fail "PUT JAR failed" |
| 146 | +fi |
| 147 | + |
| 148 | +begin_test "Upload POM" |
| 149 | +# shellcheck disable=SC2086 |
| 150 | +if curl -sf $CURL_TIMEOUT -X PUT "${MAVEN_URL}/${POM_REL}" \ |
| 151 | + -u "${ADMIN_USER}:${ADMIN_PASS}" \ |
| 152 | + -H "Content-Type: application/xml" \ |
| 153 | + --data-binary "@${POM_FILE}" >/dev/null 2>&1; then |
| 154 | + pass |
| 155 | +else |
| 156 | + fail "PUT POM failed" |
| 157 | +fi |
| 158 | + |
| 159 | +# --------------------------------------------------------------------------- |
| 160 | +# Resolve artifact_id of the JAR. |
| 161 | +# --------------------------------------------------------------------------- |
| 162 | +begin_test "Resolve artifact_id for the JAR" |
| 163 | +jar_re="${MVN_ARTIFACT}-${VERSION}\\.jar$" |
| 164 | +# shellcheck disable=SC2086 |
| 165 | +list_status=$(curl -s -o "${WORK_DIR}/list.json" -w '%{http_code}' $CURL_TIMEOUT \ |
| 166 | + -H "$(auth_header)" \ |
| 167 | + "${BASE_URL}/api/v1/repositories/${REPO_KEY}/artifacts") || list_status="000" |
| 168 | +if [ "$list_status" = "200" ]; then |
| 169 | + ARTIFACT_ID=$(jq -er --arg re "$jar_re" \ |
| 170 | + '.items | map(select(((.path // "") | test($re)) or ((.name // "") | test($re)))) | first | .id // empty' \ |
| 171 | + < "${WORK_DIR}/list.json" 2>/dev/null || true) |
| 172 | + # Fallback: some builds key the grouped artifact by version, not jar path. |
| 173 | + if [ -z "$ARTIFACT_ID" ]; then |
| 174 | + ARTIFACT_ID=$(jq -er --arg v "$VERSION" \ |
| 175 | + '.items | map(select(.version == $v)) | first | .id // empty' \ |
| 176 | + < "${WORK_DIR}/list.json" 2>/dev/null || true) |
| 177 | + fi |
| 178 | +fi |
| 179 | +if [ -n "$ARTIFACT_ID" ]; then |
| 180 | + echo " artifact_id=${ARTIFACT_ID}" |
| 181 | + pass |
| 182 | +else |
| 183 | + fail "could not resolve artifact_id (list HTTP ${list_status})" \ |
| 184 | + "$(head -c 400 "${WORK_DIR}/list.json" 2>/dev/null || true)" |
| 185 | + end_suite |
| 186 | + exit 1 |
| 187 | +fi |
| 188 | + |
| 189 | +# --------------------------------------------------------------------------- |
| 190 | +# Generate the SBOM and assert it is NOT empty (the #870 core assertion). |
| 191 | +# --------------------------------------------------------------------------- |
| 192 | +begin_test "POST /api/v1/sbom returns 200 with component_count >= 2" |
| 193 | +sbom_payload=$(jq -n --arg id "$ARTIFACT_ID" \ |
| 194 | + '{artifact_id: $id, format: "cyclonedx", force_regenerate: true}') |
| 195 | +# shellcheck disable=SC2086 |
| 196 | +sbom_status=$(curl -s -o "${WORK_DIR}/sbom.json" -w '%{http_code}' $CURL_TIMEOUT \ |
| 197 | + -X POST -H "$(auth_header)" -H "Content-Type: application/json" \ |
| 198 | + -d "$sbom_payload" "${BASE_URL}/api/v1/sbom") || sbom_status="000" |
| 199 | + |
| 200 | +if [ "$sbom_status" != "200" ]; then |
| 201 | + fail "POST /api/v1/sbom returned HTTP ${sbom_status}" \ |
| 202 | + "$(head -c 400 "${WORK_DIR}/sbom.json" 2>/dev/null || true)" |
| 203 | + end_suite |
| 204 | + exit 1 |
| 205 | +fi |
| 206 | +component_count=$(jq -r '.component_count // 0' < "${WORK_DIR}/sbom.json") |
| 207 | +echo " component_count=${component_count}" |
| 208 | +if [ "${component_count:-0}" -ge 2 ]; then |
| 209 | + pass |
| 210 | +else |
| 211 | + # This is the #870 silent-empty class: a POM that names two compile |
| 212 | + # dependencies produced fewer than two components. |
| 213 | + fail "declared-dependency SBOM has component_count=${component_count} (expected >= 2; #870 regression)" \ |
| 214 | + "$(head -c 600 "${WORK_DIR}/sbom.json" 2>/dev/null || true)" |
| 215 | +fi |
| 216 | + |
| 217 | +# --------------------------------------------------------------------------- |
| 218 | +# Fetch the full document and assert the declared components, the resolved |
| 219 | +# property version, the maven purls, and test-scope exclusion. |
| 220 | +# --------------------------------------------------------------------------- |
| 221 | +begin_test "Fetch SBOM content for component assertions" |
| 222 | +# shellcheck disable=SC2086 |
| 223 | +get_status=$(curl -s -o "${WORK_DIR}/content.json" -w '%{http_code}' $CURL_TIMEOUT \ |
| 224 | + -H "$(auth_header)" \ |
| 225 | + "${BASE_URL}/api/v1/sbom/by-artifact/${ARTIFACT_ID}?format=cyclonedx") || get_status="000" |
| 226 | +if [ "$get_status" = "200" ] && jq -e '.content.components | type == "array"' \ |
| 227 | + < "${WORK_DIR}/content.json" >/dev/null 2>&1; then |
| 228 | + pass |
| 229 | +else |
| 230 | + fail "GET /api/v1/sbom/by-artifact returned HTTP ${get_status} or no components array" \ |
| 231 | + "$(head -c 400 "${WORK_DIR}/content.json" 2>/dev/null || true)" |
| 232 | + end_suite |
| 233 | + exit 1 |
| 234 | +fi |
| 235 | + |
| 236 | +begin_test "Property-versioned dependency (guava) present with resolved purl" |
| 237 | +if jq -e --arg purl "$GUAVA_PURL" \ |
| 238 | + '.content.components | any(.purl == $purl)' \ |
| 239 | + < "${WORK_DIR}/content.json" >/dev/null 2>&1; then |
| 240 | + pass |
| 241 | +else |
| 242 | + # Proves the storage POM fallback + ${property} interpolation: the stored |
| 243 | + # metadata keeps the literal ${guava.version}; resolution requires reading |
| 244 | + # the POM back and interpolating against <properties>. |
| 245 | + fail "guava not present as ${GUAVA_PURL} (property resolution / declared-dep extraction failed)" \ |
| 246 | + "$(jq -c '[.content.components[]?.purl]' < "${WORK_DIR}/content.json" 2>/dev/null | head -c 600)" |
| 247 | +fi |
| 248 | + |
| 249 | +begin_test "Literal-versioned dependency (commons-lang3) present with purl" |
| 250 | +if jq -e --arg purl "$COMMONS_PURL" \ |
| 251 | + '.content.components | any(.purl == $purl)' \ |
| 252 | + < "${WORK_DIR}/content.json" >/dev/null 2>&1; then |
| 253 | + pass |
| 254 | +else |
| 255 | + fail "commons-lang3 not present as ${COMMONS_PURL}" \ |
| 256 | + "$(jq -c '[.content.components[]?.purl]' < "${WORK_DIR}/content.json" 2>/dev/null | head -c 600)" |
| 257 | +fi |
| 258 | + |
| 259 | +begin_test "Test-scoped dependency (junit-jupiter) is excluded" |
| 260 | +if jq -e '.content.components | any((.name // "") | test("junit-jupiter"))' \ |
| 261 | + < "${WORK_DIR}/content.json" >/dev/null 2>&1; then |
| 262 | + fail "test-scoped junit-jupiter leaked into the SBOM (should be excluded)" \ |
| 263 | + "$(jq -c '[.content.components[]?.name]' < "${WORK_DIR}/content.json" 2>/dev/null | head -c 600)" |
| 264 | +else |
| 265 | + pass |
| 266 | +fi |
| 267 | + |
| 268 | +# --------------------------------------------------------------------------- |
| 269 | +# Honest completeness signal: a declared-only SBOM (no scanner inventory) must |
| 270 | +# be marked declared or partial, never an authoritative "complete". |
| 271 | +# --------------------------------------------------------------------------- |
| 272 | +begin_test "Completeness signal is declared or partial (not authoritative-empty)" |
| 273 | +completeness=$(jq -r ' |
| 274 | + (.content.metadata.properties // []) |
| 275 | + | map(select(.name == "artifact-keeper:scan-completeness")) |
| 276 | + | first | .value // ""' < "${WORK_DIR}/content.json" 2>/dev/null || true) |
| 277 | +echo " scan-completeness=${completeness:-<absent>}" |
| 278 | +case "$completeness" in |
| 279 | + declared|partial) |
| 280 | + pass |
| 281 | + ;; |
| 282 | + complete|"") |
| 283 | + # "complete" or a missing signal on a declared-only artifact would mean the |
| 284 | + # SBOM claims an authoritative full inventory it does not have (#870 class). |
| 285 | + fail "completeness signal is '${completeness:-<absent>}' for a declared-only SBOM; expected 'declared' or 'partial'" |
| 286 | + ;; |
| 287 | + *) |
| 288 | + fail "unexpected completeness signal '${completeness}'" |
| 289 | + ;; |
| 290 | +esac |
| 291 | + |
| 292 | +end_suite |
0 commit comments