#!/bin/sh # Uploads your build's SBOM file(s) to Declarable's /api/v1 and decides pass or fail. # Plain POSIX sh plus curl: no jq, no bash-only syntax, so it runs under `sh` on Linux and under # Git Bash on Windows. Same DECLARABLE_* variables, same exit codes as # integrations/github-action/upload.mjs: 0 = uploaded and inside your thresholds, or uploaded with # the findings not checked (said in words); 1 = a threshold was crossed; 2 = the upload itself did not work. A re-run of a version that already exists is not # an upload failure: the same files again record nothing new, and the findings are checked as usual. # "Checked" is the product read's currentRelease.checked for the release this job uploaded: if that # release is not the product's current one, or has not been matched, nothing is checked. # # Variables: DECLARABLE_API_KEY, DECLARABLE_PRODUCT_ID, DECLARABLE_SBOM_PATH (one or # more files or globs separated by spaces or newlines, such as "build/spdx/*.spdx west.yml"; no # spaces inside a path), DECLARABLE_VERSION, DECLARABLE_API_BASE_URL, # DECLARABLE_REPLACE (true|false), DECLARABLE_RELEASED_AT (YYYY-MM-DD, optional), DECLARABLE_FAIL_ON_GAP (blocker|major|minor|none), # DECLARABLE_FAIL_ON_EXPLOITED (true|false), and the optional build fields DECLARABLE_COMMIT # (default $CI_COMMIT_SHA, then $GITHUB_SHA; "none" sends none), DECLARABLE_BUILD_ID, # DECLARABLE_IMAGE_SHA256 and DECLARABLE_SUPPORTED_UNTIL (YYYY-MM-DD). DECLARABLE_KEEP_PATHS=true sends each file # under its path relative to the folder the script runs in, not its base name (for PlatformIO, whose # reader takes the environment from .pio/libdeps// in the path); the default is the base name. key=${DECLARABLE_API_KEY:-} product=${DECLARABLE_PRODUCT_ID:-} sbom=${DECLARABLE_SBOM_PATH:-} version=${DECLARABLE_VERSION:-${CI_COMMIT_SHA:-${GITHUB_SHA:-}}} base=${DECLARABLE_API_BASE_URL:-https://declarable.io} base=${base%/} replace=$(printf '%s' "${DECLARABLE_REPLACE:-false}" | tr 'A-Z' 'a-z') released_at=$(printf '%s' "${DECLARABLE_RELEASED_AT:-}" | tr -d ' \t\r\n') commit=$(printf '%s' "${DECLARABLE_COMMIT:-${CI_COMMIT_SHA:-${GITHUB_SHA:-}}}" | tr -d ' ') [ "$(printf '%s' "$commit" | tr 'A-Z' 'a-z')" != none ] || commit= build_id=$(printf '%s' "${DECLARABLE_BUILD_ID:-}" | tr -d ' ') image_sha=$(printf '%s' "${DECLARABLE_IMAGE_SHA256:-}" | tr -d ' ') supported_until=$(printf '%s' "${DECLARABLE_SUPPORTED_UNTIL:-}" | tr -d ' ') fail_gap=$(printf '%s' "${DECLARABLE_FAIL_ON_GAP:-blocker}" | tr 'A-Z' 'a-z') keep_paths=$(printf '%s' "${DECLARABLE_KEEP_PATHS:-false}" | tr 'A-Z' 'a-z') fail_exploited=$(printf '%s' "${DECLARABLE_FAIL_ON_EXPLOITED:-true}" | tr 'A-Z' 'a-z') command -v curl >/dev/null 2>&1 || { echo "curl is not installed. This script needs curl on the PATH." >&2; exit 2; } tmp=$(mktemp -d 2>/dev/null) || { echo "Cannot create a temporary folder." >&2; exit 2; } trap 'rm -rf "$tmp"' EXIT HUP INT TERM # Removes the key from any text before it is printed, even if the server echoed it back. redact() { sed 's/dk_[A-Za-z0-9]*_[A-Za-z0-9_-]*/dk_***/g'; } stop() { printf '%s\n' "$2" | redact >&2 exit "$1" } # curl reads its settings from stdin so the key never appears in the process list. # Usage: call METHOD URL OUTFILE [curl args...]; prints the HTTP status. Response headers land in # $tmp/headers.txt, read by call_retry for Retry-After. $limit is the whole-request time limit in # seconds: the upload route may run 300s, so the upload waits longer than that and reads do not. limit=30 call() { method=$1; url=$2; out=$3; shift 3 : > "$tmp/headers.txt" printf 'header = "Authorization: Bearer %s"\n' "$key" | curl -sS -K - --connect-timeout 30 --max-time "$limit" -X "$method" -D "$tmp/headers.txt" -o "$out" -w '%{http_code}' "$@" "$url" } # "message":"..." out of a flattened JSON body, the way release_field pulls id and url. error_message() { grep -o '"message":"[^"]*"' "$1" | head -n 1 | cut -d'"' -f4 } # Same as call, but retries once after a 5xx or a dropped connection (not after a timeout: the # server may still be working), and once on a 429 whose Retry-After is 60s or less. call_retry() { method=$1; url=$2; out=$3; shift 3 status=$(call "$method" "$url" "$out" "$@"); rc=$? if [ "$rc" -ne 28 ]; then case $rc:$status in 0:5??|[1-9]*:*) echo "Connection failed or server answered HTTP $status (curl exit $rc). Waiting 5s and retrying once." >&2 sleep 5 status=$(call "$method" "$url" "$out" "$@"); rc=$? ;; esac fi if [ "$rc" -eq 0 ] && [ "$status" = 429 ]; then retry_after=$(grep -i '^retry-after:' "$tmp/headers.txt" | tail -n 1 | tr -d '\r' | cut -d: -f2 | tr -d ' \t') case $retry_after in ''|*[!0-9]*) retry_after= ;; esac if [ -n "$retry_after" ] && [ "$retry_after" -le 60 ]; then tr -d ' \t\r\n' < "$out" > "$tmp/retry-flat.json" 2>/dev/null || : > "$tmp/retry-flat.json" msg=$(error_message "$tmp/retry-flat.json") if [ -n "$msg" ]; then printf '%s\n' "$msg Waiting ${retry_after}s and retrying once." | redact >&2 else echo "Rate limited (HTTP 429). Waiting ${retry_after}s and retrying once." >&2 fi sleep "$retry_after" status=$(call "$method" "$url" "$out" "$@"); rc=$? fi fi printf '%s' "$status" return $rc } echo "Thresholds: DECLARABLE_FAIL_ON_GAP=$fail_gap (blocker|major|minor|none); DECLARABLE_FAIL_ON_EXPLOITED=$fail_exploited (true|false)." [ -n "$key" ] || stop 2 "No API key. Set DECLARABLE_API_KEY from your CI's secret store." [ -n "$product" ] || stop 2 "No product id. Set DECLARABLE_PRODUCT_ID." [ -n "$sbom" ] || stop 2 "No SBOM path. Set DECLARABLE_SBOM_PATH." if [ -z "$version" ]; then version=$(git rev-parse HEAD 2>/dev/null) || stop 2 "No version. Set DECLARABLE_VERSION." fi # The date goes to the server untouched: it reads it as a UTC day and refuses one in the future. case $released_at in '') ;; [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) stop 2 "DECLARABLE_RELEASED_AT must look like 2026-09-30 (YYYY-MM-DD); got \"$released_at\"." ;; esac # The same checks, in the same words, as the releases route (lib/products/releases.ts # parseBuildIdentity), so a value the server would refuse stops the job before any upload. image_hash=$(printf '%s' "$image_sha" | sed 's/^[sS][hH][aA]256://') if [ -n "$image_sha" ]; then case $image_hash in *[!0-9a-fA-F]*) stop 2 "DECLARABLE_IMAGE_SHA256: A firmware image hash is 64 hex characters." ;; esac [ "${#image_hash}" -eq 64 ] || stop 2 "DECLARABLE_IMAGE_SHA256: A firmware image hash is 64 hex characters." fi [ "${#build_id}" -le 200 ] || stop 2 "DECLARABLE_BUILD_ID: Keep the build id under 200 characters." [ "${#commit}" -le 200 ] || stop 2 "DECLARABLE_COMMIT: Keep the commit under 200 characters." case $supported_until in '') ;; [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) # A real calendar day: month 01-12, day 01 up to that month's length (February by leap year). y=${supported_until%%-*}; rest=${supported_until#*-}; mo=${rest%%-*}; d=${rest#*-} mo=${mo#0}; d=${d#0}; y=${y#"${y%%[!0]*}"}; y=${y:-0} case $mo in 1|3|5|7|8|10|12) dim=31 ;; 4|6|9|11) dim=30 ;; 2) if [ $((y % 400)) -eq 0 ] || { [ $((y % 4)) -eq 0 ] && [ $((y % 100)) -ne 0 ]; }; then dim=29; else dim=28; fi ;; *) dim=0 ;; esac [ "$d" -ge 1 ] && [ "$d" -le "$dim" ] || stop 2 "DECLARABLE_SUPPORTED_UNTIL: Give the support date as YYYY-MM-DD." ;; *) stop 2 "DECLARABLE_SUPPORTED_UNTIL: Give the support date as YYYY-MM-DD." ;; esac case $fail_gap in blocker|major|minor|none) ;; *) stop 2 "DECLARABLE_FAIL_ON_GAP must be blocker, major, minor or none; got \"$fail_gap\"." ;; esac # One -F per file, each file once. The sbom words are split first with globbing off, then each # word is globbed on its own here in the shell, so a pattern that matches nothing can be named. # `**` is the shell's: plain sh treats it like `*` and does not recurse. # shellcheck disable=SC2086 set -- n=0 total=0 seen=' ' unmatched= set -f for word in $sbom; do set +f found=no for f in $word; do [ -f "$f" ] || continue found=yes case $seen in *" $f "*) ;; *) seen="$seen$f " if [ "$keep_paths" = true ]; then rel=${f#./} case $rel in /*|[A-Za-z]:*|..|../*|*/../*) stop 2 "DECLARABLE_KEEP_PATHS is on but $f is not under the folder the script runs in. Run it from the project folder." ;; *[\;,\"]*) stop 2 "DECLARABLE_KEEP_PATHS cannot send $f: a path with ; , or a quote in it breaks curl's form syntax." ;; esac # promise: the releases route keeps a part's file name as sent, path included (measured 11 Oct 2026) set -- "$@" -F "files[]=@$f;filename=$rel" else set -- "$@" -F "files[]=@$f" fi n=$((n + 1)) size=$(wc -c < "$f" 2>/dev/null | tr -d ' ') total=$((total + ${size:-0})) ;; esac done [ "$found" = yes ] || unmatched="$unmatched \"$word\"" set -f done set +f [ -z "$unmatched" ] || stop 2 "No file matches$unmatched. Check that the SBOM step ran first and wrote there." [ "$n" -gt 0 ] || stop 2 "No file in DECLARABLE_SBOM_PATH." echo "Sending $n file(s), $total bytes total." product_url="$base/api/v1/products/$product" if [ "$replace" = true ]; then set -- "$@" --form-string replace=true fi # promise: releasedAt is read by the releases route and stored by setReleasedAt if [ -n "$released_at" ]; then set -- "$@" --form-string "releasedAt=$released_at" fi # promise: commit, buildId, imageSha256 and supportedUntil are read by the releases route and stored with the release [ -z "$commit" ] || set -- "$@" --form-string "commit=$commit" [ -z "$build_id" ] || set -- "$@" --form-string "buildId=$build_id" [ -z "$image_sha" ] || set -- "$@" --form-string "imageSha256=$image_hash" [ -z "$supported_until" ] || set -- "$@" --form-string "supportedUntil=$supported_until" # --form-string, not -F: curl reads a -F value starting with @ or < as a file, and a commit sha, # build id or hash could start with either. limit=330 status=$(call_retry POST "$product_url/releases" "$tmp/release.json" --form-string "version=$version" "$@"); rc=$? if [ "$rc" -eq 28 ]; then stop 2 "Upload failed: no answer after ${limit}s. The release may have been recorded; sending the same files again is safe." elif [ "$rc" -ne 0 ]; then stop 2 "Upload failed: curl could not reach $base (curl exit $rc)." fi limit=30 cat "$tmp/release.json" | redact echo tr -d ' \t\r\n' < "$tmp/release.json" > "$tmp/release-flat.json" release_field() { grep -o "\"$1\":\"[^\"]*\"" "$tmp/release-flat.json" | head -n 1 | cut -d'"' -f4; } release_url= release_id= # A 2xx answer only counts as an upload when its body holds a release object with an id, as the # Node client requires. A wrong base URL, a proxy or a captive portal can answer 200 with a page. case $status in 2??) release_obj=$(grep -o '"release":{[^}]*}' "$tmp/release-flat.json" | head -n 1) release_id=$(printf '%s' "$release_obj" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4) release_url=$(printf '%s' "$release_obj" | grep -o '"url":"[^"]*"' | head -n 1 | cut -d'"' -f4) [ -n "$release_id" ] || stop 2 "Upload answered without a release in the body. HTTP $status from $product_url/releases; check DECLARABLE_API_BASE_URL ($base)." ;; esac case $status in 200) if grep -q '"unchanged":true' "$tmp/release-flat.json"; then echo "Release $version already uploaded with the same files; nothing new was recorded." else echo "Uploaded $n file(s) as $version." fi ;; 2??) echo "Uploaded $n file(s) as $version." replaced=$(grep -o '"replaced":{[^}]*}' "$tmp/release-flat.json" | head -n 1) if [ -n "$replaced" ]; then replaced_version=$(printf '%s' "$replaced" | grep -o '"version":"[^"]*"' | head -n 1 | cut -d'"' -f4) replaced_id=$(printf '%s' "$replaced" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4) if printf '%s' "$replaced" | grep -q '"kept":true'; then echo "Replaced release $replaced_version ($replaced_id): it was kept under a new label rather than deleted." else echo "Replaced release $replaced_version ($replaced_id)." fi fi ;; 409) if grep -q '"code":"duplicate_version"' "$tmp/release-flat.json"; then # The id sits under "release" now; a self-hosted copy older than that sends a top-level # "releaseId". Drop the fallback one release cycle after every copy has updated. release=$(grep -o '"release":{[^}]*}' "$tmp/release-flat.json" | head -n 1) if [ -n "$release" ]; then release_id=$(printf '%s' "$release" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4) release_url=$(printf '%s' "$release" | grep -o '"url":"[^"]*"' | head -n 1 | cut -d'"' -f4) else release_id=$(release_field releaseId) release_url="$base/app/products/$product/releases/$release_id" fi echo "Release $version already exists ($release_id) with different files. Nothing was replaced; set DECLARABLE_REPLACE=true to replace it. $release_url" else msg=$(error_message "$tmp/release-flat.json") stop 2 "Upload refused: ${msg:-HTTP $status}." fi ;; 413) if grep -q '"error":{' "$tmp/release-flat.json"; then msg=$(error_message "$tmp/release-flat.json") stop 2 "Upload refused: ${msg:-HTTP 413}." else stop 2 "Upload refused: the host refused the request for its size before Declarable read it ($total bytes)." fi ;; *) msg=$(error_message "$tmp/release-flat.json") stop 2 "Upload refused: ${msg:-HTTP $status}." ;; esac # --- what is open in the release just uploaded ----------------------------------------------- # The reply is flattened to one line and read with grep: no jq to install. checked=yes unchecked_reason= status=$(call_retry GET "$product_url" "$tmp/product.json") || status=000 tr -d ' \t\r\n' < "$tmp/product.json" > "$tmp/flat.json" 2>/dev/null || : > "$tmp/flat.json" case $status in 2??) ;; *) checked=no msg=$(error_message "$tmp/flat.json") reason=${msg:-"HTTP $status"} unchecked_reason="the product read failed ($reason)" note="The findings could not be read ($reason)." [ "$status" = 403 ] && note="$note Give the key the products:read scope to check them." echo "$note" ;; esac # Does the read say anything about THIS release? If not, none of its numbers are used. # promise: GET /api/v1/products/:id answers currentRelease {id, version, checked} (api/v1/products/[id]/route.ts) if [ "$checked" = yes ]; then tr -d '\r\n' < "$tmp/product.json" > "$tmp/lines.json" if grep -q '"currentRelease":null' "$tmp/flat.json"; then unchecked_reason="the product has no current release" elif ! grep -q '"currentRelease":{' "$tmp/flat.json"; then unchecked_reason="this Declarable server does not say whether the release has been matched" else current=$(grep -o '"currentRelease":{[^}]*}' "$tmp/flat.json" | head -n 1) current_id=$(printf '%s' "$current" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4) if [ -n "$release_id" ] && [ "$current_id" != "$release_id" ]; then current_version=$(grep -o '"currentRelease": *{[^}]*}' "$tmp/lines.json" | head -n 1 | grep -o '"version": *"[^"]*"' | head -n 1 | cut -d'"' -f4) unchecked_reason="release $version is not the product's current release ($current_version is), and findings are read for the current release only" elif ! printf '%s' "$current" | grep -q '"checked":true'; then unchecked_reason="the release has not been matched yet" fi fi [ -z "$unchecked_reason" ] || checked=no fi if [ "$checked" = no ]; then echo "Gaps: Not checked yet ($unchecked_reason)" fi failures= if [ "$checked" = yes ]; then # "exploited": occurs once in the body, so it is read by itself: carving out the # openVulnerabilities object first stops at the first closing brace (byStatus') and misses it. exploited=$(grep -o '"exploited":[0-9]*' "$tmp/flat.json" | head -n 1 | cut -d: -f2) if [ -n "$exploited" ]; then echo "Open vulnerabilities known to be exploited: $exploited" if [ "$fail_exploited" != false ] && [ "$exploited" -gt 0 ]; then failures="$exploited open vulnerabilities are known to be exploited. " fi else if grep -q '"openVulnerabilities"' "$tmp/flat.json"; then missing='the answer has "openVulnerabilities" but no "exploited" count in it' else missing='the answer has no "openVulnerabilities" field' fi echo "Open vulnerabilities known to be exploited: Not checked yet ($missing)" unchecked_reason="exploited vulnerabilities were not checked ($missing)" fi if [ "$fail_gap" != none ]; then if grep -q '"gaps":null' "$tmp/flat.json"; then echo "Gaps: Not checked yet (the product has no current release)" [ -n "$unchecked_reason" ] || unchecked_reason="gaps were not checked (the product has no current release)" elif grep -q '"gaps"' "$tmp/flat.json"; then blockers=$(grep -o '"severity":"blocker"' "$tmp/flat.json" | wc -l | tr -d ' ') majors=$(grep -o '"severity":"major"' "$tmp/flat.json" | wc -l | tr -d ' ') minors=$(grep -o '"severity":"minor"' "$tmp/flat.json" | wc -l | tr -d ' ') echo "Gaps: $blockers blocker, $majors major, $minors minor" # Titles come from "severity":"x","title":"..." pairs, which the API writes side by side. The # reply is re-read with spaces kept, because titles have them; if the pairs are not found # the counts above and the release link below are all that is printed. No gap title holds a # quote today; one that did would be cut at it. tr -d '\r\n' < "$tmp/product.json" > "$tmp/lines.json" case $fail_gap in blocker) shown='blocker' ;; major) shown='blocker|major' ;; minor) shown='blocker|major|minor' ;; none) shown='^$' ;; esac grep -oE '"severity": *"(blocker|major|minor)", *"title": *"[^"]*"' "$tmp/lines.json" | sed -e 's/^"severity": *"//' -e 's/", *"title": *"/: /' -e 's/"$//' | grep -E "^($shown):" > "$tmp/titles.txt" || : > "$tmp/titles.txt" listed=$(wc -l < "$tmp/titles.txt" | tr -d ' ') head -n 10 "$tmp/titles.txt" | sed 's/^/ /' | redact if [ "$listed" -gt 10 ]; then echo " and $((listed - 10)) more" fi case $fail_gap in blocker) over=$blockers ;; major) over=$((blockers + majors)) ;; minor) over=$((blockers + majors + minors)) ;; esac if [ "$over" -gt 0 ]; then failures="$failures$over gap(s) at \"$fail_gap\" or worse. " fi else echo "DECLARABLE_FAIL_ON_GAP is \"$fail_gap\", but this reply has no gap report, so gaps were not checked." [ -n "$unchecked_reason" ] || unchecked_reason="gaps were not checked (this reply has no gap report)" fi fi fi [ -z "$release_url" ] || echo "Release: $release_url" echo "Draft findings from your SBOM, for review. Not legal advice and not a certification." if [ -n "$failures" ]; then stop 1 "Failed: $failures" fi if [ -n "$unchecked_reason" ]; then # Exit stays 0: an upload that worked is not a failed build. echo "Uploaded. Findings not checked: $unchecked_reason." else echo "Inside your thresholds." fi