Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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