Skip to content

Commit d444d62

Browse files
authored
test(sbom): pin declared-dependency SBOM for native Maven uploads (#870) (#222)
* test(sbom): pin declared-dependency SBOM for native Maven uploads (#870) Adds tests/security/test-sbom-declared-deps.sh, a release-gate E2E that proves the declared-dependency SBOM source landed in artifact-keeper#870 / PR #1553. A Maven jar PUT does not trigger a scan, so there are no scan_packages rows. Before the fix the SBOM read path returned an empty component list and the customer fetched "components": [] for an artifact whose POM names real dependencies. The test publishes a placeholder jar plus a POM declaring two compile dependencies (one ${property}-versioned, one literal) and one test-scoped dependency, then asserts: - POST /api/v1/sbom returns component_count >= 2 - the property-versioned guava resolves to pkg:maven/com.google.guava/guava@32.1.3-jre (exercises the storage POM fallback + <properties> interpolation) - commons-lang3 appears with its maven purl - the test-scoped junit-jupiter is excluded - the document carries a declared/partial completeness signal, never an authoritative "complete" with an empty inventory Gated behind a new require_feature flag sbom_declared_dependencies (>= 1.2.0) so pre-fix backends skip loudly instead of flapping. Auto-discovered by the security suite, which release-gate.yml already runs. Closes #220 * test(sbom): enable sbom_declared_dependencies in 1.2.x branch feature set The release gate resolves require_feature via the branch-aware AK_FEATURES env (feature-flags.sh), not the version probe. Without registering the new flag in AK_BACKEND_BRANCH_1_2_X, the gate treated it as explicitly-disabled and skipped test-sbom-declared-deps.sh on main/1.2.x. Add it so the test runs against a backend carrying artifact-keeper#870 (PR #1553).
1 parent c0cfe5b commit d444d62

3 files changed

Lines changed: 301 additions & 0 deletions

File tree

tests/lib/common.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ _feature_min_version() {
210210
"maven_virtual_snapshot") echo "1.2.0" ;;
211211
"guest_access_toggle") echo "1.2.0" ;;
212212
"opensearch_indexing") echo "1.2.0" ;;
213+
# sbom_declared_dependencies: SBOM generation merges the artifact's own
214+
# declared dependencies (Maven POM, npm package.json, Helm Chart.yaml) with
215+
# scanner output, so an artifact a scanner cannot enumerate (a bare Maven
216+
# jar with no lockfile) no longer produces an authoritative empty SBOM, and
217+
# the document carries a completeness signal (complete/declared/partial/
218+
# none). artifact-keeper#870, lands in v1.2.0. Pre-1.2.0 backends return an
219+
# empty SBOM for the declared-only case, so the gate skips there.
220+
"sbom_declared_dependencies") echo "1.2.0" ;;
213221
# proxy_stampede_protection: ProxyService gains a per-(repo,path) semaphore
214222
# capping concurrent upstream fetches at proxy_max_concurrent_fetches and
215223
# emitting 503 when proxy_queue_timeout_secs fires. Tracked by backend

tests/lib/feature-flags.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ AK_BACKEND_BRANCH_1_2_X="\
9696
virtual_member_strict_contract \
9797
webhook_event_producer \
9898
proxy_ttl_eviction_correctness \
99+
sbom_declared_dependencies \
99100
"
100101

101102
# main: everything 1.2.x has, plus anything in-flight on main.
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

Comments
 (0)