Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Ensure shell scripts and GitHub Actions workflow files always use LF line
# endings, even when checked out/edited on Windows. Scripts with CRLF fail at
# runtime on Linux CI runners with errors like:
# line 1: $'\r': command not found
*.sh text eol=lf
.github/workflows/*.yml text eol=lf
.github/workflows/*.yaml text eol=lf
246 changes: 245 additions & 1 deletion .github/workflows/ci-main-pull-request-stub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,248 @@ jobs:

# udf1: 'default' # user defined flag 1
# udf2: 'default' # user defined flag 2
# udf3: 'default' # user defined flag 3
# udf3: 'default' # user defined flag 3

# ---------------------------------------------------------------------------
# Combined Rust + npm + Habitat-package SBOM generation, BlackDuck import
#
# Motivation: BlackDuck Detect's `cargo tree` scan (above) only covers
# components/builder-api, the only Rust project in this repo — all other
# deployable components (builder-api-proxy, builder-memcached,
# builder-minio, builder-datastore) wrap third-party binaries packaged as
# plain Habitat packages, which are invisible to any Cargo-based tooling.
# components/builder-web is an npm/Angular frontend whose dependencies are
# likewise invisible to Cargo-based tooling.
#
# This job generates a CycloneDX SBOM for builder-api via cargo-cyclonedx
# (single target — everything here ships x86_64-linux only), a second
# CycloneDX SBOM for builder-web's npm dependencies via cyclonedx-npm, plus
# a third CycloneDX fragment built by querying the public Builder API for
# the dependency trees (tdeps) of this repo's top-level Habitat packages in
# the on-prem-base channel, filtered to core-origin packages. All three are
# merged into one SBOM and imported into the same BlackDuck project/version
# the SCA scan above already created.
# ---------------------------------------------------------------------------
generate-habitat-sbom:
name: 'Generate Habitat Package + Cargo + npm SBOM and Import to BlackDuck'
runs-on: ubuntu-latest
env:
# Match the project name used by call-ci-main-pr-check-pipeline above
BD_PROJECT_NAME: ${{ github.event.repository.name }}

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set BD_VERSION_NAME from VERSION file
run: echo "BD_VERSION_NAME=$(cat VERSION)" >> "$GITHUB_ENV"

# Read the pinned toolchain from rust-toolchain at the repo root so we
# don't have to hardcode the version here.
- name: Read Rust toolchain version
id: rust-version
run: |
echo "toolchain=$(grep ^channel rust-toolchain | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
id: toolchain
with:
toolchain: ${{ steps.rust-version.outputs.toolchain }}
Comment thread
mwrock marked this conversation as resolved.
- run: rustup override set ${{ steps.toolchain.outputs.name }}

- name: Install cargo-cyclonedx
uses: ClementTsang/cargo-action@v0.0.7
with:
command: install
args: cargo-cyclonedx --locked

# Download the CycloneDX CLI used to merge the cargo SBOM with the
# Habitat package dependency fragment.
- name: Install CycloneDX CLI
run: |
curl -sSL https://github.com/CycloneDX/cyclonedx-cli/releases/latest/download/cyclonedx-linux-x64 \
-o /usr/local/bin/cyclonedx-cli
chmod +x /usr/local/bin/cyclonedx-cli
Comment thread
mwrock marked this conversation as resolved.
Comment thread
mwrock marked this conversation as resolved.

# Generate the Rust dependency SBOM for the whole workspace (only
# components/builder-api and its internal library crates are Rust;
# everything ships x86_64-linux only, so a single target is enough).
- name: Generate Cargo SBOM — Linux x86_64
run: cargo cyclonedx --all --format json --target x86_64-unknown-linux-gnu --target-in-filename

# components/builder-web is the only npm project in this repo.
- name: Read Node.js version
id: node-version
run: |
echo "version=$(jq -r '.engines.node' components/builder-web/package.json | grep -oE '[0-9]+' | head -1)" >> "$GITHUB_OUTPUT"

- name: Install Node.js toolchain
uses: actions/setup-node@v4
with:
node-version: ${{ steps.node-version.outputs.version }}

- name: Install builder-web npm dependencies
working-directory: components/builder-web
run: npm ci

# Generate the npm dependency SBOM for builder-web using the official
# CycloneDX npm generator. Written to the repo root so the merge step
# below (which globs for "*.cdx.json") picks it up automatically.
- name: Generate npm SBOM for builder-web
working-directory: components/builder-web
run: npx --yes @cyclonedx/cyclonedx-npm --output-format json --output-file ../../builder-web-npm.cdx.json

# Query the public Builder API for the core-origin transitive
# dependencies of this repo's top-level, deployable Habitat packages
# (habitat/builder-api-proxy, habitat/builder-api,
# habitat/builder-memcached, habitat/builder-minio,
# habitat/builder-datastore) in the on-prem-base channel.
- name: Generate Habitat package dependency SBOM fragment
run: bash support/sbom/generate-habitat-pkg-deps.sh > habitat-pkg-deps.cdx.json

# Merge the per-crate cargo SBOMs, the builder-web npm SBOM, and the
# Habitat package dependency fragment into one deduplicated CycloneDX
# document. cargo cyclonedx writes one *.cdx.json per workspace member
# into each member's directory, and builder-web-npm.cdx.json was
# written to the repo root above. We use find to collect them all,
# excluding the target/ build directory.
- name: Merge SBOMs
run: |
SBOM_FILES=$(find . -name "*.cdx.json" -not -path "*/target/*" -not -name "habitat-pkg-deps.cdx.json" | sort | tr '\n' ' ')
echo "Merging files: $SBOM_FILES habitat-pkg-deps.cdx.json"
cyclonedx-cli merge \
--input-files $SBOM_FILES habitat-pkg-deps.cdx.json \
--output-file sbom-all.json \
--output-format json \
--output-version v1_4

# Inject a version-based serialNumber and the current version so BlackDuck
# treats each release upload as a distinct scan (prevents "already mapped" errors).
# Also strip first-party crates from this workspace so only third-party
# dependencies and the Habitat package fragment remain:
# artifactory-client, builder_core, github-api-client, oauth-client,
# token-generator, and anything starting with "habitat" (except the
# "Habitat core_" prefixed entries from the Habitat package fragment).
jq --arg serial "urn:uuid:builder-${BD_VERSION_NAME}" --arg ver "$BD_VERSION_NAME" \
'.serialNumber = $serial
| .metadata.component.version = $ver
| .components = [.components[] | select(
(.name | ascii_downcase | startswith("habitat core_")) or
(
(.name | ascii_downcase | startswith("habitat") | not) and
([.name] | inside(["artifactory-client","builder_core","github-api-client","oauth-client","token-generator"]) | not)
)
)]' \
sbom-all.json > sbom-versioned.json
mv sbom-versioned.json sbom-all.json
echo "Components after filtering: $(jq '.components|length' sbom-all.json)"

# Always upload the merged SBOM as an artifact for auditing/review
- name: Upload merged SBOM artifact
uses: actions/upload-artifact@v7
with:
name: cyclonedx-sbom-habitat
path: sbom-all.json
retention-days: 90

# Import the merged SBOM into the same BlackDuck project/version that
# the SCA scan above already created, so the Habitat package deps and
# any Rust deps missed by Detect's cargo tree scan appear in the same BOM.
#
# Required secrets (already present in repo/org):
# BLACKDUCK_SBOM_URL — e.g. https://your-instance.blackducksoftware.com
# BLACKDUCK_SCA_TOKEN — a BlackDuck personal access token with BOM write rights
- name: Import SBOM into BlackDuck
# if: github.event_name != 'pull_request'
env:
BLACKDUCK_URL: ${{ secrets.BLACKDUCK_SBOM_URL }}
BLACKDUCK_API_TOKEN: ${{ secrets.BLACKDUCK_SCA_TOKEN }}
run: |
set -euo pipefail

# Authenticate and obtain a short-lived bearer token
BEARER=$(curl -sSf -X POST \
"${BLACKDUCK_URL}/api/tokens/authenticate" \
-H "Authorization: token ${BLACKDUCK_API_TOKEN}" \
-H "Accept: application/vnd.blackducksoftware.user-4+json" \
| jq -r '.bearerToken')
if [ -z "${BEARER}" ] || [ "${BEARER}" = "null" ]; then
echo "ERROR: Failed to obtain bearer token."
exit 1
fi

# URL-encode BD_PROJECT_NAME and BD_VERSION_NAME for use in query strings.
# BD_VERSION_NAME comes from the VERSION file and may contain '+' (semver
# build metadata) or other reserved characters.
BD_PROJECT_ENCODED=$(printf '%s' "${BD_PROJECT_NAME}" | jq -sRr @uri)
BD_VERSION_ENCODED=$(printf '%s' "${BD_VERSION_NAME}" | jq -sRr @uri)

# Look up the project by exact name (BD search is substring, so we filter client-side)
PROJECT_BODY=$(curl -sSf \
"${BLACKDUCK_URL}/api/projects?q=name:${BD_PROJECT_ENCODED}&limit=10" \
--oauth2-bearer "${BEARER}" \
-H "Accept: application/vnd.blackducksoftware.project-detail-4+json")
PROJECT_HREF=$(echo "${PROJECT_BODY}" | jq -r \
--arg name "${BD_PROJECT_NAME}" \
'.items[] | select(.name == $name) | ._meta.href' | head -1)
if [ -z "${PROJECT_HREF}" ] || [ "${PROJECT_HREF}" = "null" ]; then
echo "ERROR: BlackDuck project '${BD_PROJECT_NAME}' not found."
echo "Available matches: $(echo "${PROJECT_BODY}" | jq -r '.items[].name')"
exit 1
fi

# Look up the project version by exact name; create it if it doesn't exist yet
VERSION_BODY=$(curl -sSf \
"${PROJECT_HREF}/versions?q=versionName:${BD_VERSION_ENCODED}&limit=10" \
--oauth2-bearer "${BEARER}" \
-H "Accept: application/vnd.blackducksoftware.project-detail-5+json")
VERSION_HREF=$(echo "${VERSION_BODY}" | jq -r \
--arg ver "${BD_VERSION_NAME}" \
'.items[] | select(.versionName == $ver) | ._meta.href' | head -1)
if [ -z "${VERSION_HREF}" ] || [ "${VERSION_HREF}" = "null" ]; then
echo "Version '${BD_VERSION_NAME}' not found — creating it..."
VERSION_HREF=$(curl -sS -o /tmp/bd_create_resp.json -w "%{header_json}" -X POST \
"${PROJECT_HREF}/versions" \
--oauth2-bearer "${BEARER}" \
-H "Content-Type: application/vnd.blackducksoftware.project-detail-4+json" \
-H "Accept: application/vnd.blackducksoftware.project-detail-5+json" \
-d "{\"versionName\":\"${BD_VERSION_NAME}\",\"phase\":\"DEVELOPMENT\",\"distribution\":\"EXTERNAL\"}" \
| jq -r '.location[0] // empty')
# Prefer Location header; fall back to re-fetching the version href
if [ -z "${VERSION_HREF}" ]; then
VERSION_HREF=$(curl -sSf \
"${PROJECT_HREF}/versions?q=versionName:${BD_VERSION_ENCODED}&limit=10" \
--oauth2-bearer "${BEARER}" \
-H "Accept: application/vnd.blackducksoftware.project-detail-5+json" \
| jq -r --arg ver "${BD_VERSION_NAME}" \
'.items[] | select(.versionName == $ver) | ._meta.href' | head -1)
fi
if [ -z "${VERSION_HREF}" ] || [ "${VERSION_HREF}" = "null" ]; then
echo "ERROR: Failed to create or locate BlackDuck version '${BD_VERSION_NAME}'."
echo "Create response: $(cat /tmp/bd_create_resp.json)"
exit 1
fi
echo "Created version: ${VERSION_HREF}"
fi

echo "Uploading SBOM ($(jq '.components|length' sbom-all.json) components) to project '${BD_PROJECT_NAME}' version '${BD_VERSION_NAME}'"

# POST /api/scan/data as multipart/form-data per BD API spec.
# projectName, versionName, and autocreate are form parts, not query params.
# Content type for the file part must be application/vnd.cyclonedx (no +json).
HTTP_STATUS=$(curl -sS -o /tmp/bd_upload_resp.json -w "%{http_code}" -X POST \
"${BLACKDUCK_URL}/api/scan/data" \
--oauth2-bearer "${BEARER}" \
-F "file=@sbom-all.json;type=application/vnd.cyclonedx" \
-F "projectName=${BD_PROJECT_NAME}" \
-F "versionName=${BD_VERSION_NAME}" \
-F "autocreate=true")

echo "BlackDuck SBOM upload HTTP status: ${HTTP_STATUS}"
if [[ "${HTTP_STATUS}" != "2"* ]]; then
echo "ERROR: SBOM upload failed with HTTP ${HTTP_STATUS}"
echo "Response: $(cat /tmp/bd_upload_resp.json)"
exit 1
fi
echo "SBOM upload complete. Components will appear in BlackDuck after async processing."
122 changes: 122 additions & 0 deletions support/sbom/generate-habitat-pkg-deps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# generate-habitat-pkg-deps.sh
#
# Queries the public Builder API for the full transitive dependency trees
# (tdeps) of this repo's top-level, deployable Habitat packages and emits a
# CycloneDX fragment covering their core-origin runtime dependencies.
#
# Motivation: only components/builder-api is a Rust project (its SBOM is
# generated separately via cargo-cyclonedx). The other deployable components
# (builder-api-proxy, builder-memcached, builder-minio, builder-datastore)
# wrap third-party binaries packaged as Habitat "core" packages, which are
# invisible to cargo tooling. This script captures those dependencies by
# asking Builder directly for each top-level package's dependency tree, the
# same way /habitat's support/sbom scripts derive its core-origin fragment.
#
# All top-level packages here are built for x86_64-linux only, so unlike
# /habitat's multi-platform SBOM job, only a single target is queried.
#
# Usage:
# bash support/sbom/generate-habitat-pkg-deps.sh > habitat-pkg-deps.cdx.json
#
# Environment:
# BLDR_URL Builder base URL (default: https://bldr.habitat.sh)
# CHANNEL Channel to inspect (default: on-prem-base)
# TARGET Habitat package target (default: x86_64-linux)
#
# Requires: curl, jq

set -euo pipefail

BLDR_URL="${BLDR_URL:-https://bldr.habitat.sh}"
CHANNEL="${CHANNEL:-on-prem-base}"
TARGET="${TARGET:-x86_64-linux}"

# The top-level, deployable Habitat packages that make up this product.
TOP_LEVEL_PACKAGES=(
"habitat/builder-api-proxy"
"habitat/builder-api"
"habitat/builder-memcached"
"habitat/builder-minio"
"habitat/builder-datastore"
)

echo "Builder URL: $BLDR_URL" >&2
echo "Channel: $CHANNEL" >&2
echo "Target: $TARGET" >&2
echo "Top-level pkgs: ${TOP_LEVEL_PACKAGES[*]}" >&2
echo "" >&2

# CORE_DEPS is a set keyed by "name@version" to deduplicate across all
# top-level packages' dependency trees.
declare -A CORE_DEPS # key: "name@version" -> "1"
declare -A CORE_DEP_META # key: "name@version" -> "name version"

add_dep() {
local name="$1" version="$2"
local key="${name}@${version}"
if [ -z "${CORE_DEPS[$key]+_}" ]; then
CORE_DEPS["$key"]="1"
CORE_DEP_META["$key"]="${name} ${version}"
fi
}

for pkg in "${TOP_LEVEL_PACKAGES[@]}"; do
origin="${pkg%%/*}"
pkg_name="${pkg##*/}"
url="${BLDR_URL}/v1/depot/channels/${origin}/${CHANNEL}/pkgs/${pkg_name}/latest?target=${TARGET}"
printf " Fetching %-40s [%-16s] ... " "${pkg}" "${TARGET}" >&2
if response=$(curl -sSf "$url" 2>/dev/null); then
mapfile -t deps < <(
echo "$response" \
| jq -r '.tdeps[]? | select(.origin == "core") | "\(.name)/\(.version)"' \
2>/dev/null \
|| true
)
printf "%d core deps\n" "${#deps[@]}" >&2
for dep in "${deps[@]}"; do
[ -z "$dep" ] && continue
add_dep "${dep%%/*}" "${dep##*/}"
done
else
echo "ERROR: failed to fetch ${pkg} from channel ${CHANNEL} (target ${TARGET})" >&2
exit 1
fi
done

echo "" >&2
echo "Unique core {name, version} pairs found: ${#CORE_DEPS[@]}" >&2
echo "" >&2

if [ "${#CORE_DEPS[@]}" -eq 0 ]; then
echo "ERROR: No core-origin packages found. Check BLDR_URL, CHANNEL, and TOP_LEVEL_PACKAGES." >&2
exit 1
fi

# Build one CycloneDX component per unique {name, version}, using the same
# naming/purl convention as /habitat's habitat-core-deps.cyclonedx.json so
# BlackDuck maps them to the same KB entries rather than creating duplicates.
component_jsons=()
for key in $(printf '%s\n' "${!CORE_DEP_META[@]}" | sort); do
read -r pkg_name version <<< "${CORE_DEP_META[$key]}"
display_name="Habitat core_${pkg_name}"
purl="pkg:generic/${pkg_name}@${version}"
component_jsons+=(
"$(jq -cn \
--arg name "$display_name" \
--arg version "$version" \
--arg purl "$purl" \
'{type: "library", name: $name, version: $version, purl: $purl}')"
)
done

components_json=$(printf '%s\n' "${component_jsons[@]}" | jq -s '.')

jq -n \
--argjson components "$components_json" \
'{
bomFormat: "CycloneDX",
specVersion: "1.4",
version: 1,
components: $components
}'
Loading