Skip to content
Draft
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
28 changes: 28 additions & 0 deletions .pipelines/templates/.builder-release-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ steps:
vstsPackageVersion: '0.0.230'
downloadDirectory: vhdbuilder/

# Compliance Engine (benchmark-agnostic) scan — opt-in shadow of the CIS-CAT
# scan above. Downloads the assessor + Ubuntu benchmark MOFs onto the agent;
# vhd-scanning.sh then stages them onto the scan VM and publishes native JSON.
# This is fully additive: it no-ops unless ENABLE_COMPLIANCE_ENGINE_SCAN=true.
#
# Package names + versions are pinned (PR-gated) in the manifest
# vhdbuilder/packer/compliance-versions.json — bumping requires a PR, exactly
# like the CIS scanner version pin above. The compliance-engine packages live
# on the ComplianceEnginePackages feed in the SAME ADO org, so basic auth
# (System.AccessToken) is used and the build identity needs Feed Reader there.
- template: ./.compliance-engine-scan-template.yaml
parameters:
condition: and(succeeded(), eq(variables['ENABLE_COMPLIANCE_ENGINE_SCAN'], 'true'), eq(variables['OS_SKU'], 'Ubuntu'), in(variables['OS_VERSION'], '22.04', '24.04'))
feedName: ComplianceEnginePackages

- task: DownloadPipelineArtifact@2
condition: and(or(eq(variables.OS_SKU, 'CBLMariner'), eq(variables.OS_SKU, 'AzureLinux')), eq(variables.IMG_OFFER, 'cbl-mariner'))
displayName: 'Download Kata CC UVM artifact'
Expand Down Expand Up @@ -363,6 +378,19 @@ steps:
cis-report.txt
TargetFolder: '$(Build.ArtifactStagingDirectory)'

# Compliance Engine native JSON results (+ assessor logs). Non-blocking: the
# glob simply matches nothing when the scan is disabled or produced no output.
- task: CopyFiles@2
condition: and(eq(variables.OS_SKU, 'Ubuntu'), in(variables.OS_VERSION, '22.04', '24.04'))
continueOnError: true
displayName: Copy Compliance Engine Reports
inputs:
Comment on lines +383 to +387
SourceFolder: '$(System.DefaultWorkingDirectory)'
Contents: |
compliance-engine-*.json
compliance-engine-*.log
TargetFolder: '$(Build.ArtifactStagingDirectory)'

- task: PublishPipelineArtifact@0
displayName: Publish Release Notes
inputs:
Expand Down
236 changes: 236 additions & 0 deletions .pipelines/templates/.compliance-engine-scan-template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#################################################################################
# .compliance-engine-scan-template.yaml #
# #
# Shared ADO steps template that downloads the compliance-engine-assessor #
# binary and the benchmark MOF packages from an Azure Artifacts NuGet feed onto #
# the build agent. It is the AgentBaker counterpart of the Augmentation #
# Engine's .pipelines/templates/test/compliance-engine-scan.yml, and is meant #
# to be the shared entry point for evolving compliance/benchmark integrations #
# (CIS today, STIG next) in the ADO VHD builder. #
# #
# Package NAMES and VERSIONS are NOT hard-coded here: they are pinned in the #
# repo manifest vhdbuilder/packer/compliance-versions.json and read at runtime #
# with jq. Bumping a version therefore always requires a PR (PR-gating), the #
# same model ACL uses. A non-blocking staleness warning fires when a pin is #
# older than stalenessThresholdDays. #
# #
# Unlike the Augmentation Engine template, this one does NOT run the audit on #
# the agent: the assessor must audit the live node OS, so the actual audit runs #
# on the scan VM built from the VHD (see vhdbuilder/packer/vhd-scanning.sh -> #
# compliance-engine-report.sh). This template only performs the reusable, #
# benchmark-agnostic download and lays artifacts down at a predictable path. #
# #
# Output layout (under downloadRoot): #
# compliance-engine-assessor the assessor binary (chmod +x) #
# mof/<key>.mof one MOF per benchmark key in the manifest #
# #
# Dependencies (agent tools): curl, unzip, jq, bash. #
# #
# Feed auth: #
# authMode=basic (default): System.AccessToken basic auth, same ADO org as #
# the running pipeline. The build identity needs Feed Reader on the feed. #
# authMode=bearer: workload-identity Azure service connection (Feed Reader), #
# for a feed in a different ADO org. Provide feedServiceConnection. #
#################################################################################

parameters:
- name: condition
type: string
default: succeeded()
displayName: 'Runtime condition gating every step (e.g. restrict to specific OS/versions)'

- name: manifestPath
type: string
default: '$(Build.SourcesDirectory)/vhdbuilder/packer/compliance-versions.json'
displayName: 'Path to the PR-gated version manifest'

- name: stalenessThresholdDays
type: number
default: 180
displayName: 'Emit a non-blocking warning when a pinned version is older than this many days'

- name: feedName
type: string
default: 'ComplianceEnginePackages'
displayName: 'Azure Artifacts NuGet feed name hosting the assessor + benchmark packages'

- name: feedProject
type: string
default: ''
displayName: 'ADO project hosting the feed (empty = org-scoped feed)'

- name: feedOrg
type: string
default: ''
displayName: 'ADO organization hosting the feed (empty = derive from System.CollectionUri)'

- name: authMode
type: string
default: 'basic'
values:
- basic
- bearer
displayName: 'Feed auth: basic (System.AccessToken, same org) or bearer (service connection, cross-org)'

- name: feedServiceConnection
type: string
default: ''
displayName: 'Azure service connection with Feed Reader (required when authMode=bearer)'

- name: downloadRoot
type: string
default: '$(Build.SourcesDirectory)/vhdbuilder/compliance-engine'
displayName: 'Agent directory to place the assessor binary and MOF files'

steps:
# ── Phase 0: resolve a feed auth header ────────────────────────────────────────
# Both auth modes converge on a single secret variable, ceFeedAuthHeader, so the
# download step below stays identical regardless of how the feed is reached.
- ${{ if eq(parameters.authMode, 'bearer') }}:
- task: AzureCLI@2
displayName: 'Compliance Engine: resolve feed auth (bearer)'
condition: ${{ parameters.condition }}
inputs:
azureSubscription: ${{ parameters.feedServiceConnection }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
# 499b84ac-... is the well-known Azure DevOps resource ID; the token it
# returns is accepted by the Azure Artifacts flat2 API.
token=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
echo "##vso[task.setvariable variable=ceFeedAuthHeader;issecret=true]Authorization: Bearer ${token}"
- ${{ else }}:
- script: |
set -euo pipefail
# Basic auth with the build identity's token (username empty, password=token).
header="Authorization: Basic $(printf ':%s' "${SYSTEM_ACCESSTOKEN}" | base64 | tr -d '\n')"
echo "##vso[task.setvariable variable=ceFeedAuthHeader;issecret=true]${header}"
displayName: 'Compliance Engine: resolve feed auth (basic)'
condition: ${{ parameters.condition }}
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)

# ── Phase 1: download assessor + benchmark MOFs (manifest-driven) ──────────────
- script: |
set -euo pipefail

manifest="${{ parameters.manifestPath }}"
if [ ! -f "${manifest}" ]; then
echo "##[error]Compliance version manifest not found: ${manifest}"
exit 1
fi
command -v jq >/dev/null 2>&1 || { echo "##[error]jq is required but not installed"; exit 1; }

# Derive the flat2 base URL.
feed_org="${{ parameters.feedOrg }}"
if [ -z "${feed_org}" ]; then
col_uri="${SYSTEM_COLLECTIONURI%/}"
if [ "${col_uri#https://dev.azure.com/}" != "${col_uri}" ]; then
feed_org="${col_uri#https://dev.azure.com/}"
else
feed_org="${col_uri#https://}"; feed_org="${feed_org%%.*}"
fi
fi
feed_project="${{ parameters.feedProject }}"
feed_name="${{ parameters.feedName }}"
if [ -n "${feed_project}" ]; then
base_url="https://pkgs.dev.azure.com/${feed_org}/${feed_project}/_packaging/${feed_name}/nuget/v3/flat2"
else
base_url="https://pkgs.dev.azure.com/${feed_org}/_packaging/${feed_name}/nuget/v3/flat2"
fi

download_root="${{ parameters.downloadRoot }}"
mof_dir="${download_root}/mof"
tmp_dir="${download_root}/.download"
rm -rf "${download_root}"
mkdir -p "${mof_dir}" "${tmp_dir}"

threshold="${{ parameters.stalenessThresholdDays }}"
now=$(date -u +%s)
# Splits a '<package>@<version>' pin into _name / _ver (package names contain
# no '@'). Fails on a malformed pin.
split_pin() {
local label="$1" pin="$2"
_name="${pin%@*}"; _ver="${pin##*@}"
if [ "${_name}" = "${pin}" ] || [ -z "${_name}" ] || [ -z "${_ver}" ]; then
echo "##[error]'${label}' pin must be '<package>@<version>' (got '${pin}')"
exit 1
fi
}
# Compliance package versions are '<YYYYMMDD>.<buildid>[-suffix]', so the pin
# date is the version's leading 8 digits — no separate 'updated' field.
warn_if_stale() {
local label="$1" ver="$2" datestr upd_epoch age
datestr="${ver:0:8}"
if ! printf '%s' "$datestr" | grep -Eq '^[0-9]{8}$' || ! upd_epoch=$(date -u -d "${datestr:0:4}-${datestr:4:2}-${datestr:6:2}" +%s 2>/dev/null); then
echo "##vso[task.logissue type=warning]${label}: cannot derive a date from version '${ver}'; skipping staleness check."
return 0
fi
age=$(( (now - upd_epoch) / 86400 ))
if [ "${age}" -gt "${threshold}" ]; then
echo "##vso[task.logissue type=warning]${label} version '${ver}' is ${age} days old (threshold ${threshold}). Open a PR to bump vhdbuilder/packer/compliance-versions.json."
fi
}

# Downloads and extracts a NuGet package via the flat2 API.
# $1 = package name, $2 = version, $3 = extract dir
download_pkg() {
local name="$1" ver="$2" out="$3"
# NuGet normalizes 2-part versions (X.Y[-pre] -> X.Y.0[-pre]) in flat2 URLs.
if printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+(-.*)?$'; then
local pre=""
case "$ver" in *-*) pre="-${ver#*-}"; ver="${ver%%-*}";; esac
ver="${ver}.0${pre}"
fi
local url="${base_url}/${name}/${ver}/${name}.${ver}.nupkg"
echo "Downloading ${name} ${ver}"
echo " URL: ${url}"
curl -sSfL -H "${CE_FEED_AUTH_HEADER}" "${url}" -o "${tmp_dir}/${name}.nupkg"
mkdir -p "${out}"
unzip -q -o "${tmp_dir}/${name}.nupkg" -d "${out}"
}

# Assessor pin ('<package>@<version>') from the manifest.
a_pin=$(jq -r '.assessor // empty' "${manifest}")
[ -n "${a_pin}" ] || { echo "##[error]'assessor' pin missing in ${manifest}"; exit 1; }
split_pin "assessor" "${a_pin}"; a_name="${_name}"; a_ver="${_ver}"
warn_if_stale "compliance-engine-assessor" "${a_ver}"
download_pkg "${a_name}" "${a_ver}" "${tmp_dir}/assessor"
assessor_bin=$(find "${tmp_dir}/assessor" -name 'compliance-engine-assessor' -type f | head -1)
if [ -z "${assessor_bin}" ]; then
echo "##[error]compliance-engine-assessor binary not found in package ${a_name}"
find "${tmp_dir}/assessor" -type f || true
exit 1
fi
cp "${assessor_bin}" "${download_root}/compliance-engine-assessor"
chmod +x "${download_root}/compliance-engine-assessor"
echo "Assessor staged at ${download_root}/compliance-engine-assessor"

# Benchmark MOFs: one per key under .benchmarks (each value a
# '<package>@<version>' pin), renamed to mof/<key>.mof.
bench_count=$(jq -r '(.benchmarks // {}) | length' "${manifest}")
[ "${bench_count}" -ge 1 ] || { echo "##[error]'benchmarks' must contain at least one entry in ${manifest}"; exit 1; }
for key in $(jq -r '.benchmarks | keys[]' "${manifest}"); do
b_pin=$(jq -r --arg k "$key" '.benchmarks[$k]' "${manifest}")
split_pin "${key}" "${b_pin}"; b_name="${_name}"; b_ver="${_ver}"
warn_if_stale "${key}" "${b_ver}"
download_pkg "${b_name}" "${b_ver}" "${tmp_dir}/${key}"
mof=$(find "${tmp_dir}/${key}" -name '*.mof' -type f | head -1)
if [ -z "${mof}" ]; then
echo "##[error]No .mof found in benchmark package ${b_name} (key ${key})"
find "${tmp_dir}/${key}" -type f || true
exit 1
fi
cp "${mof}" "${mof_dir}/${key}.mof"
echo "MOF for ${key} staged at ${mof_dir}/${key}.mof"
done

rm -rf "${tmp_dir}"
echo "Compliance Engine artifacts ready under ${download_root}:"
ls -lR "${download_root}" || true
displayName: 'Compliance Engine: download assessor + benchmark MOFs'
condition: ${{ parameters.condition }}
env:
CE_FEED_AUTH_HEADER: $(ceFeedAuthHeader)
SYSTEM_COLLECTIONURI: $(System.CollectionUri)
117 changes: 117 additions & 0 deletions vhdbuilder/packer/compliance-engine-report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/bin/bash
set -eux

Comment on lines +1 to +3
# compliance-engine-report.sh
#
# Runs the Compliance Engine assessor (compliance-engine-assessor) against a
# single benchmark MOF on the scan VM and uploads the native JSON result to the
# scanning storage account. This is the benchmark-agnostic successor to the
# CIS-CAT based cis-report.sh: the same assessor binary audits CIS today and
# STIG (and any future benchmark) tomorrow, driven entirely by the MOF it is
# given. It emits JSON only (no HTML report yet).
#
# It is invoked ON the scan VM (as root) via `az vm run-command invoke
# --command-id RunShellScript` from vhd-scanning.sh, mirroring how cis-report.sh
# is invoked. Parameters arrive as environment variables (the `KEY=VALUE` form
# passed to --parameters), matching cis-report.sh.
#
# This scan is intentionally NON-BLOCKING: a NonCompliant audit result still
# produces a JSON report and the script exits 0. Only the caller decides how to
# surface findings; here we never fail the build on compliance content.
#
# Parameters (environment variables):
# ASSESSOR_BLOB_NAME Blob holding the compliance-engine-assessor binary
# MOF_BLOB_NAME Blob holding the benchmark .mof to audit
# RESULT_BLOB_NAME Blob name to upload the JSON result to
# LOG_BLOB_NAME Blob name to upload the assessor --log-file output to
# STORAGE_ACCOUNT_NAME Scanning storage account
# SIG_CONTAINER_NAME Container holding the blobs above
# AZURE_MSI_RESOURCE_STRING User-assigned MSI resource ID for `az login`
# ENABLE_TRUSTED_LAUNCH "true" adds --allow-no-subscriptions to az login
# TEST_VM_ADMIN_USERNAME Admin user (kept for parity with cis-report.sh)
# OS_SKU OS SKU (informational)

ASSESSOR_BLOB_NAME=${ASSESSOR_BLOB_NAME:-""}
MOF_BLOB_NAME=${MOF_BLOB_NAME:-""}
RESULT_BLOB_NAME=${RESULT_BLOB_NAME:-""}
LOG_BLOB_NAME=${LOG_BLOB_NAME:-""}
STORAGE_ACCOUNT_NAME=${STORAGE_ACCOUNT_NAME:-""}
SIG_CONTAINER_NAME=${SIG_CONTAINER_NAME:-""}
AZURE_MSI_RESOURCE_STRING=${AZURE_MSI_RESOURCE_STRING:-""}
ENABLE_TRUSTED_LAUNCH=${ENABLE_TRUSTED_LAUNCH:-""}
TEST_VM_ADMIN_USERNAME=${TEST_VM_ADMIN_USERNAME:-"azureuser"}
OS_SKU=${OS_SKU:-""}

# Azure login helper (mirrors cis-report.sh)
login_with_user_assigned_managed_identity() {
local TYPE_FLAG="$1"
local ID=$2
LOGIN_FLAGS="--identity $TYPE_FLAG $ID"
if [ "${ENABLE_TRUSTED_LAUNCH,,}" = "true" ]; then
LOGIN_FLAGS="$LOGIN_FLAGS --allow-no-subscriptions"
fi
echo "logging into azure with flags: $LOGIN_FLAGS"
az login $LOGIN_FLAGS
}

if [ -z "$AZURE_MSI_RESOURCE_STRING" ]; then
echo "AZURE_MSI_RESOURCE_STRING must be set for az login"
exit 1
fi
Comment on lines +57 to +60
login_with_user_assigned_managed_identity "--resource-id" "$AZURE_MSI_RESOURCE_STRING"

# The assessor's input hardening (azure-osconfig InputSecurity.cpp) refuses to
# read the MOF, and refuses to write the --log-file, unless the file and its
# parent directory are owned by root and are not group/world-writable. This
# script runs as root under RunShellScript, and `mktemp -d` creates a 0700
# root-owned directory, so staging the assessor, MOF and log there satisfies
# those checks. The world-writable /tmp grandparent is not consulted.
WORK_DIR="$(mktemp -d)"
chmod 0700 "$WORK_DIR"
cleanup() {
rm -rf "$WORK_DIR" || true
}
trap cleanup EXIT

ASSESSOR_BIN="${WORK_DIR}/compliance-engine-assessor"
MOF_PATH="${WORK_DIR}/benchmark.mof"
RESULT_PATH="${WORK_DIR}/result.json"
LOG_PATH="${WORK_DIR}/compliance-engine-assessor.log"

# Download the assessor binary and the benchmark MOF from the scanning storage
# account. Both were staged there by vhd-scanning.sh running on the agent.
az storage blob download --container-name "$SIG_CONTAINER_NAME" --name "$ASSESSOR_BLOB_NAME" --file "$ASSESSOR_BIN" --account-name "$STORAGE_ACCOUNT_NAME" --auth-mode login
az storage blob download --container-name "$SIG_CONTAINER_NAME" --name "$MOF_BLOB_NAME" --file "$MOF_PATH" --account-name "$STORAGE_ACCOUNT_NAME" --auth-mode login

chmod 0755 "$ASSESSOR_BIN"
chmod 0644 "$MOF_PATH"

# Run the audit. stdout is the pure JSON result; --log-file captures all
# assessor logging (and disables console logging) so stdout stays parseable.
# Capture the exit code but never abort: a NonCompliant result is expected and
# must not fail the (shadow, non-blocking) scan.
audit_rc=0
set +e
"$ASSESSOR_BIN" --verbose --log-file "$LOG_PATH" --format json audit "$MOF_PATH" > "$RESULT_PATH" 2> "${WORK_DIR}/stderr.log"
audit_rc=$?
set -e
echo "compliance-engine-assessor exited with code: ${audit_rc}"

# Surface a little context in the run-command output for debugging.
echo "----- compliance-engine-assessor stderr -----"
cat "${WORK_DIR}/stderr.log" 2>/dev/null || true
echo "---------------------------------------------"

# Upload the JSON result and the assessor log even if the audit reported
# NonCompliant or failed, so the agent always has something to publish.
if [ -s "$RESULT_PATH" ]; then
az storage blob upload --container-name "$SIG_CONTAINER_NAME" --file "$RESULT_PATH" --name "$RESULT_BLOB_NAME" --account-name "$STORAGE_ACCOUNT_NAME" --auth-mode login --overwrite
else
echo "WARNING: assessor produced no JSON result on stdout"
fi
if [ -s "$LOG_PATH" ]; then
az storage blob upload --container-name "$SIG_CONTAINER_NAME" --file "$LOG_PATH" --name "$LOG_BLOB_NAME" --account-name "$STORAGE_ACCOUNT_NAME" --auth-mode login --overwrite
fi

echo "compliance-engine report script completed for MOF blob ${MOF_BLOB_NAME}"
exit 0
Loading
Loading